diff --git a/docs/docs-developers/docs/aztec-nr/framework-description/contract_artifact.md b/docs/docs-developers/docs/aztec-nr/framework-description/contract_artifact.md index f14bc962ca94..1b31ee6f3392 100644 --- a/docs/docs-developers/docs/aztec-nr/framework-description/contract_artifact.md +++ b/docs/docs-developers/docs/aztec-nr/framework-description/contract_artifact.md @@ -3,7 +3,7 @@ title: Contract Artifacts description: Understand the structure and contents of Aztec smart contract artifacts. tags: [contracts] sidebar_position: 13 -references: ["noir-projects/labs/noir-contracts/contracts/test/test_contract/src/main.nr", "yarn-project/stdlib/src/abi/contract_artifact.ts"] +references: ["noir-projects/labs/noir-contracts/contracts/test/test_contract/src/main.nr", "yarn-project/stdlib/src/abi/contract_artifact.ts", "yarn-project/builder/src/contract-interface-gen/typescript.ts"] --- Compiling an Aztec contract produces a contract artifact file (`.json`) containing everything needed to interact with that contract: its name, functions, their interfaces, and compiled bytecode. Since private function bytecode is never published to the network, you need this artifact file to call private functions. @@ -60,7 +60,23 @@ pub contract Globals { The example creates two groups under `outputs.globals`: `constants` and `limits`. Each group is an array of `{ name, value }` entries. Stacking attributes on `EXPORTED_SHARED_CONSTANT` exports the same global under both tags. -When working directly with an artifact, use `getGlobalsByTag` to return the named entries for one tag as raw `AbiValue` objects: +In TypeScript application code, run `aztec codegen` and read exported globals from the generated contract class. Codegen derives a static, read-only `globals` getter from `ContractArtifact.outputs.globals`, with decoded values grouped by tag: + +```typescript +GlobalsContract.globals.constants.EXPORTED_FIELD_CONSTANT; // 1234n +GlobalsContract.globals.constants.EXPORTED_STRING_CONSTANT; // 'exported' +GlobalsContract.globals.limits.EXPORTED_LIMIT_CONSTANT; // 100n +GlobalsContract.globals.constants.EXPORTED_SHARED_CONSTANT; // 7n +GlobalsContract.globals.limits.EXPORTED_SHARED_CONSTANT; // 7n +``` + +The generated `globals` getter omits the `storage` tag. Aztec.nr reserves that tag for the generated storage layout, which the contract class exposes through its `storage` getter. + +:::warning +Do not apply `#[abi(storage)]` to your own globals. Noir compiles the attribute without complaint, but the entry lands next to the generated storage layout and loading the artifact then fails with `Global '' is exported under the reserved #[abi(storage)] tag`. Pick any other tag. +::: + +When building tooling that works directly with artifacts instead of generated contract classes, use `getGlobalsByTag` to return the named entries for one tag as raw `AbiValue` objects: ```typescript import { getGlobalsByTag } from '@aztec/aztec.js/abi'; diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index ea687b04dce3..4bb233c98d07 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,9 +9,9 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD -### [Aztec.js] Contract artifacts preserve the names of `#[abi(tag)]` globals +### [Aztec.js] Generated contract classes expose `#[abi(tag)]` globals by name -Globals exported from a Noir contract with `#[abi(tag)]` now keep their names in the artifact: entries in `ContractArtifact.outputs.globals` are `{ name, value }` objects as emitted by the compiler, where the names used to be stripped on load. This lets TypeScript read contract constants by name instead of duplicating their values: +Globals exported from a Noir contract with `#[abi(tag)]` now keep their names in the artifact: entries in `ContractArtifact.outputs.globals` are `{ name, value }` objects as emitted by the compiler, where the names used to be stripped on load. After you run `aztec codegen`, the generated contract class exposes their decoded values through a read-only `globals` getter, grouped by tag: ```noir #[abi(constants)] @@ -21,9 +21,7 @@ pub global EXPORTED_STRING_CONSTANT: str<8> = "exported"; ``` ```typescript -import { getGlobalsByTag } from '@aztec/aztec.js/abi'; - -const { EXPORTED_FIELD_CONSTANT, EXPORTED_STRING_CONSTANT } = getGlobalsByTag(MyContractArtifact, 'constants'); +const { EXPORTED_FIELD_CONSTANT, EXPORTED_STRING_CONSTANT } = MyContractContract.globals.constants; ``` There is no backwards compatibility path: artifacts compiled before Noir exported global names (with bare, unnamed entries) are rejected on load and must be recompiled with the current toolchain. The artifact hash commits to the contract artifact's ABI outputs (`ContractArtifact.outputs`), so adding names to `outputs.globals` changes the artifact hash, the contract class ID, and any addresses derived from it. The PXE data schema version was bumped accordingly, so existing PXE databases resync on first open. diff --git a/yarn-project/builder/src/contract-interface-gen/codegen.test.ts b/yarn-project/builder/src/contract-interface-gen/codegen.test.ts new file mode 100644 index 000000000000..4361c9a4b8e0 --- /dev/null +++ b/yarn-project/builder/src/contract-interface-gen/codegen.test.ts @@ -0,0 +1,69 @@ +import { access, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { generateCode } from './codegen.js'; + +const cacheFile = 'codegenCache.json'; + +async function exists(filePath: string) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +describe('generateCode cache', () => { + const outputFile = 'out/CacheTest.ts'; + let workDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + workDir = await mkdtemp(path.join(tmpdir(), 'codegen-cache-')); + process.chdir(workDir); + await writeFile( + 'CacheTest.json', + JSON.stringify({ + name: 'CacheTest', + // eslint-disable-next-line camelcase + aztec_version: '1.0.0', + transpiled: true, + functions: [], + outputs: { structs: {}, globals: {} }, + // eslint-disable-next-line camelcase + file_map: {}, + }), + ); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(workDir, { recursive: true, force: true }); + }); + + it('regenerates when the cached output is missing', async () => { + await generateCode('out', 'CacheTest.json'); + expect(await exists(outputFile)).toBe(true); + + await rm(outputFile); + await generateCode('out', 'CacheTest.json'); + expect(await exists(outputFile)).toBe(true); + }); + + it('regenerates when the cache was written by a different generator version', async () => { + await generateCode('out', 'CacheTest.json'); + await writeFile(outputFile, 'stale generated output'); + + await generateCode('out', 'CacheTest.json'); + expect(await readFile(outputFile, 'utf8')).toBe('stale generated output'); + + const versioned = JSON.parse(await readFile(cacheFile, 'utf8')); + await writeFile(cacheFile, JSON.stringify({ ...versioned, cacheVersion: versioned.cacheVersion - 1 })); + + await generateCode('out', 'CacheTest.json'); + expect(await readFile(outputFile, 'utf8')).not.toBe('stale generated output'); + }); +}); diff --git a/yarn-project/builder/src/contract-interface-gen/codegen.ts b/yarn-project/builder/src/contract-interface-gen/codegen.ts index 1137b81a6a6c..45f979488baf 100644 --- a/yarn-project/builder/src/contract-interface-gen/codegen.ts +++ b/yarn-project/builder/src/contract-interface-gen/codegen.ts @@ -8,6 +8,9 @@ import path from 'path'; import { generateTypescriptContractInterface } from './typescript.js'; const cacheFilePath = './codegenCache.json'; +// Bump when the generated output changes (e.g. the typescript template), so caches written by older +// generators are invalidated even though the artifact hashes they store are still current. +const cacheVersion = 1; let cache: Record = {}; /** Generate code options */ @@ -44,8 +47,11 @@ async function generateFromNoirAbi(outputPath: string, noirAbiPath: string, opts const currentHash = await generateFileHash(noirAbiPath); const cachedInstance = isCacheValid(fileName, currentHash); if (cachedInstance && !opts.force) { - console.log(`${fileName} has not changed. Skipping generation.`); - return `${outputPath}/${cachedInstance.contractName}.ts`; + const outputFilePath = `${outputPath}/${cachedInstance.contractName}.ts`; + if (await exists(outputFilePath)) { + console.log(`${fileName} has not changed. Skipping generation.`); + return outputFilePath; + } } const file = await readFile(noirAbiPath, 'utf8'); @@ -86,12 +92,15 @@ async function generateFileHash(filePath: string) { async function readCache() { if (await exists(cacheFilePath)) { const cacheRaw = await readFile(cacheFilePath, 'utf8'); - cache = JSON.parse(cacheRaw); + const parsed = JSON.parse(cacheRaw); + cache = parsed.cacheVersion === cacheVersion ? parsed.contracts : {}; + } else { + cache = {}; } } async function writeCache() { - await writeFile(cacheFilePath, JSON.stringify(cache, null, 2), 'utf8'); + await writeFile(cacheFilePath, JSON.stringify({ cacheVersion, contracts: cache }, null, 2), 'utf8'); } function isCacheValid(contractName: string, currentHash: string) { diff --git a/yarn-project/builder/src/contract-interface-gen/typescript.test.ts b/yarn-project/builder/src/contract-interface-gen/typescript.test.ts new file mode 100644 index 000000000000..f9edbebf090a --- /dev/null +++ b/yarn-project/builder/src/contract-interface-gen/typescript.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable camelcase */ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { type AbiNamedValue, type AbiValue, emptyContractArtifact, loadContractArtifact } from '@aztec/stdlib/abi'; +import type { NoirCompiledContract } from '@aztec/stdlib/noir'; + +import { generateTypescriptContractInterface } from './typescript.js'; + +function integer(value: number): AbiValue { + return { kind: 'integer', sign: value < 0, value: Math.abs(value).toString(16) }; +} + +function contractWithGlobals(globals: Record): NoirCompiledContract { + return { + name: 'TestContract', + aztec_version: '1.0.0', + transpiled: true, + functions: [], + outputs: { structs: {}, globals }, + file_map: {}, + }; +} + +async function generateGlobals(globals: Record): Promise | undefined> { + const generated = await generateTypescriptContractInterface( + loadContractArtifact(contractWithGlobals(globals)), + './TestContract.json', + ); + const match = generated.match(/public static get globals\(\) \{\s*return (\{[\s\S]*\}) as const;/); + if (!match) { + return undefined; + } + // Evaluate the emitted literal so the assertions cover its runtime semantics, not just its text. + // eslint-disable-next-line @typescript-eslint/no-implied-eval + return new Function(`return ${match[1]}`)() as Record; +} + +// Built from a `ContractArtifact` directly: the storage getter renders `artifact.storageLayout`, so the layout is +// the unit boundary here and artifact loading is covered by the stdlib tests. +async function generateStorage(fields: Record): Promise> { + const generated = await generateTypescriptContractInterface( + { + ...emptyContractArtifact(), + name: 'TestContract', + storageLayout: Object.fromEntries(Object.entries(fields).map(([name, slot]) => [name, { slot: new Fr(slot) }])), + }, + './TestContract.json', + ); + const match = generated.match( + /public static get storage\(\)[^{]*\{\s*return (\{[\s\S]*?\}) as ContractStorageLayout/, + ); + // Evaluate the emitted literal so the assertions cover its runtime semantics, not just its text. + // eslint-disable-next-line @typescript-eslint/no-implied-eval + return new Function('Fr', `return ${match![1]}`)(function (value: bigint) { + return { value }; + }) as Record; +} + +describe('generateTypescriptContractInterface storage', () => { + it('defines a storage field named __proto__ as a regular own property', async () => { + const storage = await generateStorage({ ['__proto__']: 1, balance: 2 }); + expect(Object.getOwnPropertyDescriptor(storage, '__proto__')?.value.slot.value).toBe(1n); + expect(Object.getPrototypeOf(storage)).toBe(Object.prototype); + expect(Object.keys(storage)).toEqual(['__proto__', 'balance']); + }); +}); + +describe('generateTypescriptContractInterface globals', () => { + it('decodes every AbiValue kind into a plain typescript value, grouped by tag', async () => { + const globals = await generateGlobals({ + constants: [ + { name: 'FIELD_CONSTANT', value: integer(1234) }, + { name: 'NEGATIVE_CONSTANT', value: integer(-5) }, + { name: 'STRING_CONSTANT', value: { kind: 'string', value: 'exported' } }, + { name: 'BOOLEAN_CONSTANT', value: { kind: 'boolean', value: true } }, + { name: 'ARRAY_CONSTANT', value: { kind: 'array', value: [integer(1), integer(2)] } }, + { name: 'TUPLE_CONSTANT', value: { kind: 'tuple', fields: [integer(3), { kind: 'string', value: 'two' }] } }, + { + name: 'STRUCT_CONSTANT', + value: { + kind: 'struct', + fields: [ + { name: 'inner', value: integer(7) }, + { name: 'nested', value: { kind: 'struct', fields: [{ name: 'flag', value: integer(0) }] } }, + ], + }, + }, + // Also present under `limits`: stacking #[abi(constants)] and #[abi(limits)] on one global + // exports the same name under both tags. + { name: 'MAX_ENTRIES', value: { kind: 'string', value: 'unlimited' } }, + ], + limits: [{ name: 'MAX_ENTRIES', value: integer(100) }], + }); + + expect(globals).toEqual({ + constants: { + FIELD_CONSTANT: 1234n, + NEGATIVE_CONSTANT: -5n, + STRING_CONSTANT: 'exported', + BOOLEAN_CONSTANT: true, + ARRAY_CONSTANT: [1n, 2n], + TUPLE_CONSTANT: [3n, 'two'], + STRUCT_CONSTANT: { inner: 7n, nested: { flag: 0n } }, + MAX_ENTRIES: 'unlimited', + }, + limits: { + MAX_ENTRIES: 100n, + }, + }); + }); + + it('defines a global named __proto__ as a regular own property', async () => { + const globals = await generateGlobals({ constants: [{ name: '__proto__', value: integer(1) }] }); + expect(Object.getOwnPropertyDescriptor(globals!.constants, '__proto__')?.value).toBe(1n); + expect(Object.getPrototypeOf(globals!.constants)).toBe(Object.prototype); + }); + + it('quotes ABI tags that are not valid typescript identifiers', async () => { + const globals = await generateGlobals({ '123': [{ name: 'MY_GLOBAL', value: integer(1) }] }); + expect(globals).toEqual({ '123': { MY_GLOBAL: 1n } }); + }); + + it('defines a tag named __proto__ as a regular own property', async () => { + // `fromEntries` creates an own `__proto__` key, matching what `JSON.parse` of a compiled artifact produces. + const globals = await generateGlobals( + Object.fromEntries([['__proto__', [{ name: 'MY_GLOBAL', value: integer(1) }]]]) as Record< + string, + AbiNamedValue[] + >, + ); + expect(Object.getOwnPropertyDescriptor(globals, '__proto__')?.value).toEqual({ MY_GLOBAL: 1n }); + expect(Object.getPrototypeOf(globals)).toBe(Object.prototype); + }); + + it('emits no globals getter when the contract only exports the storage layout', async () => { + const storageLayoutValue: AbiValue = { + kind: 'struct', + fields: [ + { name: 'contract_name', value: { kind: 'string', value: 'TestContract' } }, + { + name: 'fields', + value: { + kind: 'struct', + fields: [ + { + name: 'balance', + value: { kind: 'struct', fields: [{ name: 'slot', value: integer(1) }] }, + }, + ], + }, + }, + ], + }; + const globals = await generateGlobals({ + storage: [{ name: 'STORAGE_LAYOUT_TestContract', value: storageLayoutValue }], + }); + expect(globals).toBeUndefined(); + }); +}); diff --git a/yarn-project/builder/src/contract-interface-gen/typescript.ts b/yarn-project/builder/src/contract-interface-gen/typescript.ts index 08cb36b39b46..327cb0ac202f 100644 --- a/yarn-project/builder/src/contract-interface-gen/typescript.ts +++ b/yarn-project/builder/src/contract-interface-gen/typescript.ts @@ -1,12 +1,14 @@ import { type ABIParameter, type ABIVariable, + type AbiValue, type ContractArtifact, EventSelector, type FunctionAbi, decodeFunctionSignature, getAllFunctionAbis, getDefaultInitializer, + getGlobalsByTag, isAztecAddressStruct, isBoundedVecStruct, isEthAddressStruct, @@ -225,11 +227,11 @@ function generateStorageLayoutGetter(input: ContractArtifact) { return ''; } - const storageFieldsUnionType = entries.map(([name]) => `'${name}'`).join(' | '); + const storageFieldsUnionType = entries.map(([name]) => JSON.stringify(name)).join(' | '); const layout = entries .map( ([name, { slot }]) => - `${name}: { + `${objectPropertyKey(name)}: { slot: new Fr(${slot.toBigInt()}n), }`, ) @@ -243,6 +245,74 @@ function generateStorageLayoutGetter(input: ContractArtifact) { `; } +/** + * Renders a Noir name as an object literal property key. + */ +function objectPropertyKey(name: string): string { + // A literal `__proto__` key (quoted or not) sets the object's prototype instead of defining a + // property; the computed form defines a regular own property. + if (name === '__proto__') { + return `['__proto__']`; + } + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +/** + * Renders an AbiValue as a typescript literal: integers as bigints, strings/booleans as-is, + * arrays/tuples as array literals, and structs as object literals. + */ +function abiValueToTsLiteral(value: AbiValue): string { + switch (value.kind) { + case 'boolean': + return value.value.toString(); + case 'string': + return JSON.stringify(value.value); + case 'integer': { + const magnitude = BigInt(`0x${value.value}`); + return `${value.sign ? -magnitude : magnitude}n`; + } + case 'array': + return `[${value.value.map(abiValueToTsLiteral).join(', ')}]`; + case 'tuple': + return `[${value.fields.map(abiValueToTsLiteral).join(', ')}]`; + case 'struct': + return `{ ${value.fields.map(f => `${objectPropertyKey(f.name)}: ${abiValueToTsLiteral(f.value)}`).join(', ')} }`; + } +} + +/** + * Generates a getter exposing the globals exported with `#[abi(tag)]` as decoded values, grouped by tag. + * @param input - The contract artifact. + */ +function generateGlobalsGetter(input: ContractArtifact) { + // The `storage` tag is reserved by the aztec-nr macros for the storage layout, which is already + // exposed decoded through the `storage` getter. + const tags = Object.entries(input.outputs.globals) + .filter(([tag]) => tag !== 'storage') + .sort(([a], [b]) => a.localeCompare(b)); + + if (tags.length === 0) { + return ''; + } + + const groups = tags.map(([tag]) => { + const fields = Object.entries(getGlobalsByTag(input, tag)).map( + ([name, value]) => `${objectPropertyKey(name)}: ${abiValueToTsLiteral(value)},`, + ); + return `${objectPropertyKey(tag)}: { + ${fields.join('\n ')} + },`; + }); + + return `/** Decoded values of the globals exported in this contract artifact with \`#[abi(tag)]\`, grouped by tag. */ + public static get globals() { + return { + ${groups.join('\n ')} + } as const; + } + `; +} + // events is of type AbiType async function generateEvents(events: any[] | undefined) { if (events === undefined) { @@ -310,6 +380,7 @@ export async function generateTypescriptContractInterface(input: ContractArtifac const artifactStatement = artifactImportPath && generateAbiStatement(input.name, artifactImportPath); const artifactGetter = artifactImportPath && generateArtifactGetters(input.name); const storageLayoutGetter = artifactImportPath && generateStorageLayoutGetter(input); + const globalsGetter = artifactImportPath && generateGlobalsGetter(input); const { eventDefs, events } = await generateEvents(input.outputs.structs?.events); return ` @@ -341,6 +412,8 @@ export class ${input.name}Contract extends ContractBase { ${storageLayoutGetter} + ${globalsGetter} + /** Type-safe wrappers for the public methods exposed by the contract. */ public declare methods: { ${methods.join('\n')} diff --git a/yarn-project/stdlib/src/abi/abi.ts b/yarn-project/stdlib/src/abi/abi.ts index 32b11690c8ac..abdf0d026526 100644 --- a/yarn-project/stdlib/src/abi/abi.ts +++ b/yarn-project/stdlib/src/abi/abi.ts @@ -404,6 +404,33 @@ export interface ContractArtifact { fileMap: DebugFileMap; } +/** + * Like `z.record(z.string(), value)`, but keeps a `__proto__` key: zod's record parser skips that key to protect + * itself from prototype pollution, silently dropping the entry. `__proto__` is a valid Noir identifier, so artifact + * record keys derived from user code (ABI tags, storage field names) can legitimately carry it. Entries are added + * with `Object.defineProperty`, which defines an own property instead of invoking the inherited `__proto__` setter. + */ +function recordSchemaWithProtoKey(valueSchema: TValue) { + return z.unknown().transform((input, ctx): Record> => { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + ctx.addIssue({ code: 'custom', message: `Invalid input: expected record, received ${typeof input}` }); + return z.NEVER; + } + const out: Record> = {}; + for (const key of Object.keys(input)) { + const parsed = valueSchema.safeParse((input as Record)[key]); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ code: 'custom', message: issue.message, path: [key, ...issue.path] }); + } + continue; + } + Object.defineProperty(out, key, { value: parsed.data, writable: true, enumerable: true, configurable: true }); + } + return out; + }); +} + export const ContractArtifactSchema = zodFor()( z.object({ name: z.string(), @@ -423,9 +450,9 @@ export const ContractArtifactSchema = zodFor()( } return structs; }), - globals: z.record(z.string(), z.array(AbiNamedValueSchema)), + globals: recordSchemaWithProtoKey(z.array(AbiNamedValueSchema)), }), - storageLayout: z.record(z.string(), z.object({ slot: schemas.Fr })), + storageLayout: recordSchemaWithProtoKey(z.object({ slot: schemas.Fr })), fileMap: z.record( z.coerce.number(), z.object({ diff --git a/yarn-project/stdlib/src/abi/contract_artifact.test.ts b/yarn-project/stdlib/src/abi/contract_artifact.test.ts index 460d2499eb8d..2619d70174f1 100644 --- a/yarn-project/stdlib/src/abi/contract_artifact.test.ts +++ b/yarn-project/stdlib/src/abi/contract_artifact.test.ts @@ -11,27 +11,32 @@ import { loadContractArtifact, } from './contract_artifact.js'; -const storageLayoutValue = { - kind: 'struct', - fields: [ - { name: 'contract_name', value: { kind: 'string', value: 'TestContract' } }, - { - name: 'fields', - value: { - kind: 'struct', - fields: [ - { - name: 'balance', +function storageLayoutFor(contractName: string, fields: [name: string, slot: string][]): AbiValue { + return { + kind: 'struct', + fields: [ + { name: 'contract_name', value: { kind: 'string', value: contractName } }, + { + name: 'fields', + value: { + kind: 'struct', + fields: fields.map(([name, slot]) => ({ + name, value: { kind: 'struct', - fields: [{ name: 'slot', value: { kind: 'integer', sign: false, value: '01' } }], + fields: [{ name: 'slot', value: { kind: 'integer', sign: false, value: slot } }], }, - }, - ], + })), + }, }, - }, - ], -} satisfies AbiValue; + ], + }; +} + +const storageLayoutValue = storageLayoutFor('TestContract', [['balance', '01']]); + +const fieldValue = { kind: 'integer', sign: false, value: '04d2' } satisfies AbiValue; +const stringValue = { kind: 'string', value: 'exported' } satisfies AbiValue; describe('contract_artifact', () => { it('serializes and deserializes an instance', () => { @@ -53,6 +58,131 @@ describe('contract_artifact', () => { expect(artifact.storageLayout).toEqual({ balance: { slot: new Fr(1) } }); }); + it('preserves a global exported under a tag named __proto__', () => { + // `fromEntries` creates an own `__proto__` key, matching what `JSON.parse` of a compiled artifact produces; + // an object literal would set the prototype instead of defining the key. + const globals = Object.fromEntries([['__proto__', [{ name: 'MY_GLOBAL', value: fieldValue }]]]) as Record< + string, + AbiNamedValue[] + >; + const artifact = loadContractArtifact(contractWithGlobals(globals)); + + expect(Object.keys(artifact.outputs.globals)).toEqual(['__proto__']); + expect(Object.getPrototypeOf(artifact.outputs.globals)).toBe(Object.prototype); + expect(getGlobalsByTag(artifact, '__proto__')).toEqual({ MY_GLOBAL: fieldValue }); + }); + + it('loads a storage layout field named __proto__ as a regular own property', () => { + const artifact = loadContractArtifact( + contractWithGlobals({ + storage: [ + { + name: 'STORAGE_LAYOUT_TestContract', + value: storageLayoutFor('TestContract', [ + ['__proto__', '01'], + ['balance', '02'], + ]), + }, + ], + }), + ); + + expect(Object.keys(artifact.storageLayout)).toEqual(['__proto__', 'balance']); + expect(Object.getOwnPropertyDescriptor(artifact.storageLayout, '__proto__')?.value).toEqual({ slot: new Fr(1) }); + expect(Object.getPrototypeOf(artifact.storageLayout)).toBe(Object.prototype); + }); + + it('selects the layout matching the contract name over an imported contract layout', () => { + const artifact = loadContractArtifact( + contractWithGlobals({ + storage: [ + { name: 'STORAGE_LAYOUT_OtherContract', value: storageLayoutFor('OtherContract', [['dep_balance', '63']]) }, + { name: 'STORAGE_LAYOUT_TestContract', value: storageLayoutValue }, + ], + }), + ); + expect(artifact.storageLayout).toEqual({ balance: { slot: new Fr(1) } }); + }); + + it('throws when two storage layouts declare the same contract name', () => { + // Reachable from valid Noir: a dependency contract sharing this contract's unqualified name emits an + // identically named layout global with an identical contract_name, and the compiler exports both. + expect(() => + loadContractArtifact( + contractWithGlobals({ + storage: [ + { + name: 'STORAGE_LAYOUT_TestContract', + value: storageLayoutFor('TestContract', [['dep_balance', '63']]), + }, + { name: 'STORAGE_LAYOUT_TestContract', value: storageLayoutValue }, + ], + }), + ), + ).toThrow(/Ambiguous storage layout/); + }); + + it('rejects a global exported under the reserved storage tag', () => { + // Reachable from valid Noir: `#[abi(storage)]` on a user global compiles, and the entry lands next to the + // layout the storage macro generates. + expect(() => + loadContractArtifact( + contractWithGlobals({ + storage: [ + { + name: 'MY_GLOBAL', + value: { kind: 'struct', fields: [{ name: 'x', value: { kind: 'integer', sign: false, value: '2a' } }] }, + }, + { name: 'STORAGE_LAYOUT_TestContract', value: storageLayoutValue }, + ], + }), + ), + ).toThrow(/Global 'MY_GLOBAL'.*reserved/); + }); + + it('rejects a non-layout global whose name uses the reserved storage layout prefix', () => { + // Reachable from valid Noir: `#[abi(storage)] pub global STORAGE_LAYOUT_FAKE: Field = 1;` compiles, and a + // prefix-only check accepts it while codegen drops the whole tag, silently losing the global. + expect(() => + loadContractArtifact( + contractWithGlobals({ + storage: [ + { name: 'STORAGE_LAYOUT_FAKE', value: fieldValue }, + { name: 'STORAGE_LAYOUT_TestContract', value: storageLayoutValue }, + ], + }), + ), + ).toThrow(/Global 'STORAGE_LAYOUT_FAKE'.*reserved/); + }); + + it('rejects a prefix-named struct global that is not a generated storage layout', () => { + expect(() => + loadContractArtifact( + contractWithGlobals({ + storage: [ + { + name: 'STORAGE_LAYOUT_CONFIG', + value: { kind: 'struct', fields: [{ name: 'x', value: fieldValue }] }, + }, + { name: 'STORAGE_LAYOUT_TestContract', value: storageLayoutValue }, + ], + }), + ), + ).toThrow(/Global 'STORAGE_LAYOUT_CONFIG'.*reserved/); + }); + + it('rejects a storage layout global whose name does not match its contract_name', () => { + // The #[storage] macro derives both the global name and the contract_name field from the module name, so a + // mismatch means the entry was not generated by the macro. + expect(() => + loadContractArtifact( + contractWithGlobals({ + storage: [{ name: 'STORAGE_LAYOUT_Renamed', value: storageLayoutValue }], + }), + ), + ).toThrow(/Global 'STORAGE_LAYOUT_Renamed'.*reserved/); + }); + it('loads the constants exported by the Test contract', () => { const artifact = getTestContractArtifact(); const constants = getGlobalsByTag(artifact, 'constants'); @@ -82,14 +212,21 @@ describe('contract_artifact', () => { }); describe('getGlobalsByTag', () => { - const fieldValue = { kind: 'integer', sign: false, value: '04d2' } satisfies AbiValue; - const stringValue = { kind: 'string', value: 'exported' } satisfies AbiValue; - it('returns an empty record for an unknown tag', () => { const artifact = loadContractArtifact(contractWithGlobals({})); expect(getGlobalsByTag(artifact, 'constants')).toEqual({}); }); + it.each(Object.getOwnPropertyNames(Object.prototype))( + 'returns an empty record for the absent tag %s inherited from Object.prototype', + tag => { + const artifact = loadContractArtifact( + contractWithGlobals({ constants: [{ name: 'MY_FIELD', value: fieldValue }] }), + ); + expect(getGlobalsByTag(artifact, tag)).toEqual({}); + }, + ); + it('handles global names that collide with Object prototype properties', () => { const artifact = loadContractArtifact( contractWithGlobals({ constants: [{ name: 'toString', value: fieldValue }] }), @@ -97,6 +234,16 @@ describe('contract_artifact', () => { expect(getGlobalsByTag(artifact, 'constants')).toEqual({ toString: fieldValue }); }); + it('reports a pre-cutover artifact when its globals carry no names', () => { + // Pre-cutover artifacts exported bare values without names. Already-processed artifacts are returned by + // `loadContractArtifact` without validation, so the stale shape must surface an accurate error at read time. + const artifact = { + ...loadContractArtifact(contractWithGlobals({})), + outputs: { structs: {}, globals: { constants: [fieldValue] } }, + } as unknown as Parameters[0]; + expect(() => getGlobalsByTag(artifact, 'constants')).toThrow(/predates named globals/); + }); + it('throws on duplicate names under the same tag', () => { // Reachable from valid Noir: repeating the same #[abi(tag)] attribute on one global emits the // entry once per attribute, without deduplication. diff --git a/yarn-project/stdlib/src/abi/contract_artifact.ts b/yarn-project/stdlib/src/abi/contract_artifact.ts index a39a7ef4832d..c2a2372dffe4 100644 --- a/yarn-project/stdlib/src/abi/contract_artifact.ts +++ b/yarn-project/stdlib/src/abi/contract_artifact.ts @@ -16,7 +16,6 @@ import { type AbiNamedValue, type AbiType, type AbiValue, - type BasicValue, type ContractArtifact, ContractArtifactSchema, type FieldLayout, @@ -246,6 +245,26 @@ function hasKernelFunctionInputs(params: ABIParameter[]): boolean { return firstParam?.type.kind === 'struct' && firstParam.type.path.includes('ContextInputs'); } +/** Name prefix of the storage layout globals emitted by the aztec-nr `#[storage]` macro. */ +const STORAGE_LAYOUT_GLOBAL_PREFIX = 'STORAGE_LAYOUT_'; + +/** + * Returns true if the entry matches the shape emitted by the aztec-nr `#[storage]` macro: a global named + * `STORAGE_LAYOUT_` holding a struct with a `contract_name` string equal to `` and a `fields` + * struct. + */ +function isGeneratedStorageLayout(entry: AbiNamedValue): boolean { + if (entry.value.kind !== 'struct') { + return false; + } + const contractName = entry.value.fields.find(field => field.name === 'contract_name')?.value; + if (contractName?.kind !== 'string' || entry.name !== `${STORAGE_LAYOUT_GLOBAL_PREFIX}${contractName.value}`) { + return false; + } + const fields = entry.value.fields.find(field => field.name === 'fields')?.value; + return fields?.kind === 'struct'; +} + /** * Generates a storage layout for the contract artifact. * @param contractName - The name of the compiled Noir contract. @@ -253,20 +272,38 @@ function hasKernelFunctionInputs(params: ABIParameter[]): boolean { * @returns A storage layout for the contract. */ function getStorageLayout(contractName: string, globals: Record) { + // Aztec.nr reserves the `storage` tag for the layouts its macros generate. Noir happily compiles `#[abi(storage)]` + // on a user global, so reject anything that does not match the generated shape (a reserved-prefix name alone is + // not enough): it is read below as a malformed layout, and codegen drops the whole tag when building the contract + // class. + const reserved = globals.storage?.find(entry => !isGeneratedStorageLayout(entry)); + if (reserved) { + throw new Error( + `Global '${reserved.name}' is exported under the reserved #[abi(storage)] tag. Aztec.nr reserves that tag for ` + + `the storage layout generated by the #[storage] macro; export the global under a different tag.`, + ); + } + // If another contract is imported by the main contract, its storage layout its going to also show up here. // The layout export includes the contract name, so here we can find the one that belongs to the current one and // ignore the rest. const storageExports = globals.storage?.map(entry => entry.value) ?? []; - const storageForContract = storageExports.find((storageExport): storageExport is StructValue => { + const layoutCandidates = storageExports.filter((storageExport): storageExport is StructValue => { if (storageExport.kind !== 'struct') { return false; } - const contractNameField = storageExport.fields.find(field => field.name === 'contract_name')?.value as BasicValue< - 'string', - string - >; - return contractNameField.value === contractName; + const contractNameField = storageExport.fields.find(field => field.name === 'contract_name')?.value; + return contractNameField?.kind === 'string' && contractNameField.value === contractName; }); + // Contract names carry no crate identity, so a dependency contract sharing this contract's unqualified name + // exports an indistinguishable layout. Bail out rather than silently picking one of the two. + if (layoutCandidates.length > 1) { + throw new Error( + `Ambiguous storage layout: ${layoutCandidates.length} #[abi(storage)] globals declare contract_name ` + + `'${contractName}'. Rename this contract or the same-named contract it imports.`, + ); + } + const storageForContract = layoutCandidates[0]; const storageFields = storageForContract ? ((storageForContract.fields.find(field => field.name == 'fields') as TypedStructFieldValue).value .fields as TypedStructFieldValue[]) @@ -276,14 +313,13 @@ function getStorageLayout(contractName: string, globals: Record & { slot: string }>, field) => { - const name = field.name; + // Built with `fromEntries` rather than by assignment: `acc[name] = ...` goes through the `__proto__` setter, + // so a field by that name would replace the layout's prototype instead of becoming an own property. + const entries = storageFields.map(field => { const slot = field.value.fields[0].value as IntegerValue; - acc[name] = { - slot: `0x${slot.value}`, - }; - return acc; - }, {}); + return [field.name, { slot: `0x${slot.value}` }] as const; + }); + return Object.fromEntries(entries) as Record & { slot: string }>; } /** @@ -292,7 +328,20 @@ function getStorageLayout(contractName: string, globals: Record { const globals = new Map(); - for (const entry of artifact.outputs.globals[tag] ?? []) { + // Read with an own-property check: a plain object inherits `toString`, `constructor`, etc. from + // `Object.prototype`, so for those absent tags a bare `globals[tag]` returns an inherited function instead of + // `undefined` and the `?? []` fallback never fires. + const tagged = Object.hasOwn(artifact.outputs.globals, tag) ? artifact.outputs.globals[tag] : []; + for (const entry of tagged) { + // Artifacts compiled before globals carried names hold bare values here. They are admitted unvalidated through + // the processed-artifact path of `loadContractArtifact`, so report the actual problem instead of returning a + // record keyed by the string 'undefined'. + if (typeof entry.name !== 'string') { + throw new Error( + `Globals under #[abi(${tag})] in contract ${artifact.name} have no names: the artifact predates named ` + + `globals and must be recompiled with the current toolchain.`, + ); + } if (globals.has(entry.name)) { throw new Error(`Duplicate global '${entry.name}' exported under #[abi(${tag})] in contract ${artifact.name}`); }