What are you trying to do?
Deploy Contract A, which imports Contract B as a Nargo path dependency in order to call one of its functions. After B's storage layout changes (a state variable is added, removed, or reordered — with no change to any function signature), re-derive A's address using getContractInstanceFromDeployParams with the same deployer, salt, and constructor arguments.
Expected: the derived address is identical to the one registered on-chain — A's source is unchanged, so its contract_class_id should be unchanged.
Actual: the derived address differs from the on-chain address. A's contract_class_id has silently drifted because B's new storage slot constants were embedded into A's artifact.
Code Reference
Demonstrated in defi-wonderland/aztec-boilerplate#122.
The propagation chain is rooted in how artifactMetadataHash is computed:
// stdlib/src/contract/artifact_hash.ts
export function computeArtifactMetadataHash(artifact: ContractArtifact) {
return sha256Fr(Buffer.from(deterministicStringify({ name: artifact.name, outputs: artifact.outputs }), 'utf-8'));
}
artifact.outputs is:
// stdlib/src/abi/abi.ts
outputs: {
structs: Record<string, AbiType[]>;
globals: Record<string, AbiValue[]>; // ← B's storage slot constants land here
};
When A imports B, the Noir compiler emits B's storage slot assignments as compile-time globals and propagates them into A's outputs.globals. Because outputs is serialised and hashed into artifactMetadataHash, any change to B's storage layout changes A's hash chain:
B adds / removes / reorders a storage variable
→ B's slot constants change
→ those constants appear in A's outputs.globals
→ A's artifactMetadataHash changes (sha256(name + outputs))
→ A's artifactHash changes
→ A's contract_class_id changes
→ A's deployed address changes
What does NOT propagate (and therefore does not affect A's class ID):
| Change in B |
Propagates? |
Reason |
| Function body (same signature) |
No |
Private calls cross an oracle boundary; only the 4-byte selector is embedded in A's ACIR |
| Function signature |
Compile error |
A's generated call stubs reference the old signature |
| Add / remove / reorder storage variables |
Yes |
Slot globals are embedded in A's outputs.globals |
| Add unused functions |
No |
Dead stubs, not referenced in A's bytecode |
Aztec Version
v4.1.0-rc.2
OS
No response
Browser (if relevant)
No response
Node Version
No response
Additional Context
The cross-contract call model is already interface-based at the circuit level: A's ACIR encodes only (target_address, selector, args_hash) — B's bytecode never crosses the oracle boundary. A developer inspecting how private calls work would reasonably conclude that nothing from B's internals can affect A's compilation. Storage layout is an entirely separate concern from function dispatch, and there is no call-site reason for A to know where B keeps its state.
Impact: any project that uses a path dependency to call another contract and derives its own address from its compiled artifact (e.g. in a solve or deployment script) will silently compute the wrong address whenever the upstream dependency's storage layout evolves, with no compile-time or runtime warning.
Workaround: declare a minimal interface contract for B — a stub that reproduces B's function signatures with empty bodies and no storage variables — and import the stub instead of B's full crate. Because the stub emits no slot globals, A's outputs.globals remains stable across any upstream storage changes, and A's class ID is unaffected.
What are you trying to do?
Deploy Contract A, which imports Contract B as a Nargo
pathdependency in order to call one of its functions. After B's storage layout changes (a state variable is added, removed, or reordered — with no change to any function signature), re-derive A's address usinggetContractInstanceFromDeployParamswith the same deployer, salt, and constructor arguments.Expected: the derived address is identical to the one registered on-chain — A's source is unchanged, so its
contract_class_idshould be unchanged.Actual: the derived address differs from the on-chain address. A's
contract_class_idhas silently drifted because B's new storage slot constants were embedded into A's artifact.Code Reference
Demonstrated in defi-wonderland/aztec-boilerplate#122.
The propagation chain is rooted in how
artifactMetadataHashis computed:artifact.outputsis:When A imports B, the Noir compiler emits B's storage slot assignments as compile-time globals and propagates them into A's
outputs.globals. Becauseoutputsis serialised and hashed intoartifactMetadataHash, any change to B's storage layout changes A's hash chain:What does NOT propagate (and therefore does not affect A's class ID):
outputs.globalsAztec Version
v4.1.0-rc.2
OS
No response
Browser (if relevant)
No response
Node Version
No response
Additional Context
The cross-contract call model is already interface-based at the circuit level: A's ACIR encodes only
(target_address, selector, args_hash)— B's bytecode never crosses the oracle boundary. A developer inspecting how private calls work would reasonably conclude that nothing from B's internals can affect A's compilation. Storage layout is an entirely separate concern from function dispatch, and there is no call-site reason for A to know where B keeps its state.Impact: any project that uses a path dependency to call another contract and derives its own address from its compiled artifact (e.g. in a solve or deployment script) will silently compute the wrong address whenever the upstream dependency's storage layout evolves, with no compile-time or runtime warning.
Workaround: declare a minimal interface contract for B — a stub that reproduces B's function signatures with empty bodies and no storage variables — and import the stub instead of B's full crate. Because the stub emits no slot globals, A's
outputs.globalsremains stable across any upstream storage changes, and A's class ID is unaffected.