Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ab4f05d
feat!: preserve #[abi] global names in contract artifacts (F-619)
vezenovm Aug 12, 2026
6706382
fix: handle prototype-colliding global names in getNamedContractGlobals
vezenovm Aug 12, 2026
eae0e36
Apply suggestions from code review
vezenovm Aug 12, 2026
e6a8a62
feat!: hard cutover to named #[abi] globals, re-pin standard contracts
vezenovm Aug 12, 2026
1eeb3ad
Merge branch 'merge-train/fairies' into mv/f-619-artifact-global-names
vezenovm Aug 13, 2026
1f5a0eb
docs: reword the named-globals migration note
vezenovm Aug 13, 2026
0e2db57
refactor: rename getNamedContractGlobals to getGlobalsByTag, cover mu…
vezenovm Aug 13, 2026
95c47c8
test: remove redundant globals happy-path test
vezenovm Aug 13, 2026
80b4291
feat: expose #[abi] globals as decoded values on generated contract c…
vezenovm Aug 13, 2026
b6fc96c
test: fold cross-tag global coverage into the main decode test
vezenovm Aug 13, 2026
aa3c9cd
test: cover a dual-tagged #[abi] global end to end
vezenovm Aug 13, 2026
b5345e4
Merge branch 'mv/f-619-artifact-global-names' into mv/f-619-globals-c…
vezenovm Aug 13, 2026
ae3ec0a
test: note Noir reachability of duplicate and cross-tag globals
vezenovm Aug 13, 2026
3da898d
test: pin Noir-level #[abi] global name rules in contract-snapshots
vezenovm Aug 13, 2026
5a2ca9d
Merge branch 'mv/f-619-artifact-global-names' into mv/f-619-globals-c…
vezenovm Aug 13, 2026
2744e76
update docs and pr review cleanup
vezenovm Aug 13, 2026
4231dc9
update docs and pr review cleanup
vezenovm Aug 13, 2026
d0595a6
test: snapshot repeated abi tags in compiled artifacts
vezenovm Aug 13, 2026
d4e2d49
docs: keep generated globals in codegen follow-up
vezenovm Aug 13, 2026
fc0da2f
test: keep repeated abi tag coverage focused
vezenovm Aug 13, 2026
82b8a41
Merge branch 'mv/f-619-artifact-global-names' into mv/f-619-globals-c…
vezenovm Aug 13, 2026
0e4adf9
docs: document generated contract globals
vezenovm Aug 13, 2026
2243991
Merge remote-tracking branch 'origin/merge-train/fairies' into mv/f-6…
vezenovm Aug 13, 2026
14dfad4
cache tests cleanup
vezenovm Aug 13, 2026
efba6f3
better cache test
vezenovm Aug 13, 2026
cd63917
fix(stdlib): harden artifact globals against prototype-name collision…
vezenovm Aug 14, 2026
02e6393
storage tag hardening
vezenovm Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 '<name>' 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';
Expand Down
8 changes: 3 additions & 5 deletions docs/docs-developers/docs/resources/migration_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions yarn-project/builder/src/contract-interface-gen/codegen.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
17 changes: 13 additions & 4 deletions yarn-project/builder/src/contract-interface-gen/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { contractName: string; hash: string }> = {};

/** Generate code options */
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
158 changes: 158 additions & 0 deletions yarn-project/builder/src/contract-interface-gen/typescript.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, AbiNamedValue[]>): NoirCompiledContract {
return {
name: 'TestContract',
aztec_version: '1.0.0',
transpiled: true,
functions: [],
outputs: { structs: {}, globals },
file_map: {},
};
}

async function generateGlobals(globals: Record<string, AbiNamedValue[]>): Promise<Record<string, any> | 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<string, any>;
}

// 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<string, number>): Promise<Record<string, any>> {
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<string, any>;
}

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();
});
});
Loading
Loading