diff --git a/yarn-project/aztec/src/deploy/deploy.test.ts b/yarn-project/aztec/src/deploy/deploy.test.ts index 8dfa8096e5d5..1098c59de004 100644 --- a/yarn-project/aztec/src/deploy/deploy.test.ts +++ b/yarn-project/aztec/src/deploy/deploy.test.ts @@ -196,7 +196,7 @@ describe('runDeployment', () => { } }, 300_000); - it('auto-derives interdependencies, dedupes a shared class, and registers privately', async () => { + it('auto-derives interdependencies, dedupes a shared class, registers privately, and deploys universally', async () => { const secret = Fr.fromString('0x00000000000000000000000000000000000000000000000000000000feedface'); const salt = new Fr(0); const admin = await getSchnorrInitializerlessAccountContractAddress( @@ -234,11 +234,20 @@ describe('runDeployment', () => { mode: 'register', initializerArgs: r => [r.account('admin'), 'Reg', 'REG', 18], }, + // Universal: `from` sends and pays, but the address preimage omits it. + uniToken: { + kind: 'contract', + contract: TokenContract, + from: r => r.account('admin'), + mode: 'publish', + universal: true, + initializerArgs: r => [r.account('admin'), 'Universal', 'UNI', 18], + }, } satisfies Record; const addresses: Record = {}; const capture = (ctx: Ctx) => { - for (const alias of ['token', 'token2', 'regToken']) { + for (const alias of ['token', 'token2', 'regToken', 'uniToken']) { addresses[alias] = ctx.contract(alias).toString(); } }; @@ -260,8 +269,8 @@ describe('runDeployment', () => { // Interdependency: token2's args reference token, so its dependency is auto-derived. expect(plan?.steps.find(s => s.id === 'token2')?.dependsOn).toContain('token'); - // All three resolved to distinct deterministic addresses. - expect(new Set(Object.values(addresses)).size).toBe(3); + // All four resolved to distinct deterministic addresses. + expect(new Set(Object.values(addresses)).size).toBe(4); // The register step must honor its declared deployer in the derivation — the registered // address has to match what publishing the same spec (deployer included) would produce. const expectedRegToken = await getContractInstanceFromInstantiationParams(TokenContract.artifact, { @@ -270,6 +279,12 @@ describe('runDeployment', () => { deployer: admin, }); expect(addresses.regToken).toEqual(expectedRegToken.address.toString()); + // The universal step's address must omit the deployer from the preimage. + const expectedUniToken = await getContractInstanceFromInstantiationParams(TokenContract.artifact, { + salt: new Fr(0), + constructorArgs: [admin, 'Universal', 'UNI', 18], + }); + expect(addresses.uniToken).toEqual(expectedUniToken.address.toString()); // Idempotent re-run: both publishes are on-chain and the register step sends no tx, so the // whole graph is a no-op. (A broken shared-class dedup would have thrown on the first run.) diff --git a/yarn-project/aztec/src/deploy/runner.ts b/yarn-project/aztec/src/deploy/runner.ts index d132f86043a6..0c0da537fc56 100644 --- a/yarn-project/aztec/src/deploy/runner.ts +++ b/yarn-project/aztec/src/deploy/runner.ts @@ -86,6 +86,11 @@ function isDeferred(step: ContractStep): boolean { return step.deferredInitializerArgs != null; } +/** The account that sends (and pays for) a contract step's publish tx. */ +function contractSender(step: ContractStep): (resolve: Resolver) => AztecAddress { + return step.universal ? step.from : step.deployer; +} + /** * The contract aliases a pure resolver callback looks up, extracted by dry-running it against a * resolver that records each `contract(alias)` lookup instead of resolving it. The callback's return @@ -347,7 +352,7 @@ class DeploymentRun { const accountUsedBy = new Set(); for (const alias of this.execAliases) { const step = getOrThrow(this.steps, alias, 'step'); - const address = step.kind === 'contract' ? step.deployer(this.resolver) : step.from(this.resolver); + const address = step.kind === 'contract' ? contractSender(step)(this.resolver) : step.from(this.resolver); accountUsedBy.add(address.toString()); } const stepStatus = (alias: string, step: StepSpec): DeployPlan['steps'][number]['status'] => { @@ -503,13 +508,13 @@ class DeploymentRun { /** * Publishes/registers a contract from already-computed initializer args (used upfront for * deterministic contracts, and at inventory or execution time for deferred ones). Both modes - * derive the address from the full instantiation params — deployer and secret-derived public - * keys included — so a registered contract lands on the same address publishing it would. + * derive the address from the full instantiation params — deployer (unless universal) and + * secret-derived public keys included — so a registered contract lands on the same address + * publishing it would. */ private async resolveContract(alias: string, step: ContractStep, args: unknown[]): Promise { this.publishedCache.delete(alias); // re-resolution may change the address const salt = step.salt ?? this.defaultSalt; - const deployer = step.deployer(this.resolver); const publicKeys = step.secret ? (await deriveKeys(step.secret)).publicKeys : undefined; if (step.mode === 'publish') { const deployMethod = DeployMethod.create( @@ -520,7 +525,11 @@ class DeploymentRun { args, ...(step.initializer ? { constructorNameOrArtifact: step.initializer } : {}), }, - { deployer, salt, ...(publicKeys ? { publicKeys } : {}) }, + { + ...(step.universal ? { universalDeploy: true } : { deployer: step.deployer(this.resolver) }), + salt, + ...(publicKeys ? { publicKeys } : {}), + }, ); const instance = await deployMethod.getInstance(); this.contractAddresses.set(alias, instance.address); @@ -530,7 +539,7 @@ class DeploymentRun { } else { const instance = await getContractInstanceFromInstantiationParams(step.contract.artifact, { salt, - deployer, + ...(step.universal ? {} : { deployer: step.deployer(this.resolver) }), ...(publicKeys ? { publicKeys } : {}), ...(args.length ? { constructorArgs: args } : {}), ...(step.initializer ? { constructorArtifact: step.initializer } : {}), @@ -767,7 +776,7 @@ class DeploymentRun { const units: ExecutionUnit[] = []; for (const alias of layer.filter(a => this.contractSteps.has(a))) { const step = getOrThrow(this.contractSteps, alias, 'contract'); - const account = step.deployer(this.resolver); + const account = contractSender(step)(this.resolver); units.push({ label: `publish ${alias}`, kind: 'publish', diff --git a/yarn-project/aztec/src/deploy/types.ts b/yarn-project/aztec/src/deploy/types.ts index e15a425edb0a..aaaa677a83df 100644 --- a/yarn-project/aztec/src/deploy/types.ts +++ b/yarn-project/aztec/src/deploy/types.ts @@ -67,13 +67,32 @@ export interface GeneratedContractClass { at(address: AztecAddress, wallet: Wallet): T; } +/** + * Who sends a contract step's deploy, and whether the address commits to them: + * - `deployer` — a bound deploy: the account salts + sends, and the address commits to it. + * - `universal: true` + `from` — a universal deploy: the address is the same whoever sends it, so + * it can be shared before anyone commits to deploying it; `from` sends and pays. + */ +type ContractSender = + | { + /** Account that salts + sends the deploy, e.g. `(r) => r.account("admin")`. */ + deployer: (resolve: Resolver) => AztecAddress; + universal?: never; + from?: never; + } + | { + /** Publish without binding the sender into the address. */ + universal: true; + /** Account that sends (and pays for) the deploy, e.g. `(r) => r.account("admin")`. */ + from: (resolve: Resolver) => AztecAddress; + deployer?: never; + }; + /** What a contract step declares however its initializer args are produced. */ -interface ContractStepBase { +type ContractStepBase = ContractSender & { kind: 'contract'; /** The generated contract class — provides the artifact and the typed `.at`. */ contract: GeneratedContractClass; - /** Account that salts + sends the deploy, e.g. `(r) => r.account("admin")`. */ - deployer: (resolve: Resolver) => AztecAddress; /** Per-contract salt, overriding {@link DeploymentSpec.salt}. */ salt?: Fr; /** @@ -83,15 +102,16 @@ interface ContractStepBase { secret?: Fr; /** Name of a non-default `#[initializer]` to call. Defaults to the contract's constructor. */ initializer?: string; -} +}; /** * A step that puts a contract on-chain (or in the PXE): * - `publish` → register the class + deploy the instance + run its initializer (a tx). * - `register` → private; only derive the deterministic address and register it in the PXE (no tx). * - * The address is deterministic in (class id, deployer, salt, initializer + its args). The two ways - * to supply those args are the union's two variants, so a step can only pick one. + * The address is deterministic in (class id, deployer, salt, initializer + its args) — with the + * deployer omitted for {@link ContractSender | universal} steps. The two ways to supply those args + * are the union's two variants, so a step can only pick one. */ export type ContractStep = | (ContractStepBase & {