Skip to content

Commit 64fe3cb

Browse files
authored
chore: Stricter zodFor check for schemas (#19071)
The former `satisfies ZodFor<T>` approach didn't catch missing optional properties of the type in the schema. This introduces a new Claude-generated `zodFor` function that does catch them. I suggest reviewing with whitespace diff hidden.
2 parents 762f08b + 65ab5ea commit 64fe3cb

File tree

22 files changed

+537
-429
lines changed

22 files changed

+537
-429
lines changed

yarn-project/aztec.js/src/wallet/wallet.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
type ContractMetadata,
2121
} from '@aztec/stdlib/contract';
2222
import { Gas } from '@aztec/stdlib/gas';
23-
import { AbiDecodedSchema, type ApiSchemaFor, type ZodFor, optional, schemas } from '@aztec/stdlib/schemas';
23+
import { AbiDecodedSchema, type ApiSchemaFor, optional, schemas, zodFor } from '@aztec/stdlib/schemas';
2424
import {
2525
Capsule,
2626
HashedValues,
@@ -281,28 +281,34 @@ export const BatchedMethodSchema = z.union([
281281
}),
282282
]);
283283

284-
export const ContractMetadataSchema = z.object({
285-
contractInstance: z.union([ContractInstanceWithAddressSchema, z.undefined()]),
286-
isContractInitialized: z.boolean(),
287-
isContractPublished: z.boolean(),
288-
}) satisfies ZodFor<ContractMetadata>;
284+
export const ContractMetadataSchema = zodFor<ContractMetadata>()(
285+
z.object({
286+
contractInstance: z.union([ContractInstanceWithAddressSchema, z.undefined()]),
287+
isContractInitialized: z.boolean(),
288+
isContractPublished: z.boolean(),
289+
}),
290+
);
289291

290-
export const ContractClassMetadataSchema = z.object({
291-
contractClass: z.union([ContractClassWithIdSchema, z.undefined()]),
292-
isContractClassPubliclyRegistered: z.boolean(),
293-
artifact: z.union([ContractArtifactSchema, z.undefined()]),
294-
}) satisfies ZodFor<ContractClassMetadata>;
292+
export const ContractClassMetadataSchema = zodFor<ContractClassMetadata>()(
293+
z.object({
294+
contractClass: z.union([ContractClassWithIdSchema, z.undefined()]),
295+
isContractClassPubliclyRegistered: z.boolean(),
296+
artifact: z.union([ContractArtifactSchema, z.undefined()]),
297+
}),
298+
);
295299

296300
export const EventMetadataDefinitionSchema = z.object({
297301
eventSelector: schemas.EventSelector,
298302
abiType: AbiTypeSchema,
299303
fieldNames: z.array(z.string()),
300304
});
301305

302-
export const PrivateEventSchema: ZodFor<PrivateEvent<AbiDecoded>> = z.object({
303-
event: AbiDecodedSchema,
304-
metadata: inTxSchema(),
305-
});
306+
export const PrivateEventSchema: z.ZodType<any> = zodFor<PrivateEvent<AbiDecoded>>()(
307+
z.object({
308+
event: AbiDecodedSchema,
309+
metadata: inTxSchema(),
310+
}),
311+
);
306312

307313
export const PrivateEventFilterSchema = z.object({
308314
contractAddress: schemas.AztecAddress,

yarn-project/blob-sink/src/archive/blobscan_archive_client.ts

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,48 @@
11
import type { BlobJson } from '@aztec/blob-lib/types';
22
import { createLogger } from '@aztec/foundation/log';
33
import { makeBackoff, retry } from '@aztec/foundation/retry';
4-
import { type ZodFor, schemas } from '@aztec/foundation/schemas';
4+
import { schemas, zodFor } from '@aztec/foundation/schemas';
55
import { type TelemetryClient, getTelemetryClient } from '@aztec/telemetry-client';
66

77
import { z } from 'zod';
88

99
import { BlobArchiveClientInstrumentation } from './instrumentation.js';
1010
import type { BlobArchiveClient } from './interface.js';
1111

12-
export const BlobscanBlockResponseSchema = z
13-
.object({
14-
hash: z.string(),
15-
slot: z.number().int(),
16-
number: z.number().int(),
17-
transactions: z.array(
18-
z.object({
19-
hash: z.string(),
20-
blobs: z.array(
21-
z.object({
22-
versionedHash: z.string(),
23-
data: z.string(),
24-
commitment: z.string(),
25-
proof: z.string(),
26-
size: z.number().int(),
27-
index: z.number().int().optional(), // This is the index within the tx, not within the block!
28-
}),
29-
),
30-
}),
12+
export const BlobscanBlockResponseSchema = zodFor<BlobJson[]>()(
13+
z
14+
.object({
15+
hash: z.string(),
16+
slot: z.number().int(),
17+
number: z.number().int(),
18+
transactions: z.array(
19+
z.object({
20+
hash: z.string(),
21+
blobs: z.array(
22+
z.object({
23+
versionedHash: z.string(),
24+
data: z.string(),
25+
commitment: z.string(),
26+
proof: z.string(),
27+
size: z.number().int(),
28+
index: z.number().int().optional(), // This is the index within the tx, not within the block!
29+
}),
30+
),
31+
}),
32+
),
33+
})
34+
.transform(data =>
35+
data.transactions
36+
.flatMap(tx =>
37+
tx.blobs.map(blob => ({
38+
blob: blob.data,
39+
// eslint-disable-next-line camelcase
40+
kzg_commitment: blob.commitment,
41+
})),
42+
)
43+
.map((blob, index) => ({ ...blob, index: index.toString() })),
3144
),
32-
})
33-
.transform(data =>
34-
data.transactions
35-
.flatMap(tx =>
36-
tx.blobs.map(blob => ({
37-
blob: blob.data,
38-
// eslint-disable-next-line camelcase
39-
kzg_commitment: blob.commitment,
40-
})),
41-
)
42-
.map((blob, index) => ({ ...blob, index: index.toString() })),
43-
) satisfies ZodFor<BlobJson[]>;
45+
);
4446

4547
// Response from https://api.blobscan.com/blocks?sort=desc&type=canonical
4648
export const BlobscanBlocksResponseSchema = z.object({

yarn-project/bot/src/config.ts

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Fr } from '@aztec/foundation/curves/bn254';
1414
import { type DataStoreConfig, dataConfigMappings } from '@aztec/kv-store/config';
1515
import { getVKTreeRoot } from '@aztec/noir-protocol-circuits-types/vk-tree';
1616
import { protocolContractsHash } from '@aztec/protocol-contracts';
17-
import { type ZodFor, schemas } from '@aztec/stdlib/schemas';
17+
import { schemas, zodFor } from '@aztec/stdlib/schemas';
1818
import type { ComponentsVersions } from '@aztec/stdlib/versioning';
1919

2020
import { z } from 'zod';
@@ -80,50 +80,52 @@ export type BotConfig = {
8080
ammTxs: boolean;
8181
} & Pick<DataStoreConfig, 'dataDirectory' | 'dataStoreMapSizeKb'>;
8282

83-
export const BotConfigSchema = z
84-
.object({
85-
nodeUrl: z.string().optional(),
86-
nodeAdminUrl: z.string().optional(),
87-
l1RpcUrls: z.array(z.string()).optional(),
88-
l1Mnemonic: schemas.SecretValue(z.string()).optional(),
89-
l1PrivateKey: schemas.SecretValue(z.string()).optional(),
90-
l1ToL2MessageTimeoutSeconds: z.number(),
91-
senderPrivateKey: schemas.SecretValue(schemas.Fr).optional(),
92-
senderSalt: schemas.Fr.optional(),
93-
tokenSalt: schemas.Fr,
94-
txIntervalSeconds: z.number(),
95-
privateTransfersPerTx: z.number().int().nonnegative(),
96-
publicTransfersPerTx: z.number().int().nonnegative(),
97-
feePaymentMethod: z.literal('fee_juice'),
98-
baseFeePadding: z.number().int().nonnegative(),
99-
noStart: z.boolean(),
100-
txMinedWaitSeconds: z.number(),
101-
followChain: z.enum(BotFollowChain),
102-
maxPendingTxs: z.number().int().nonnegative(),
103-
flushSetupTransactions: z.boolean(),
104-
l2GasLimit: z.number().int().nonnegative().optional(),
105-
daGasLimit: z.number().int().nonnegative().optional(),
106-
contract: z.nativeEnum(SupportedTokenContracts),
107-
maxConsecutiveErrors: z.number().int().nonnegative(),
108-
stopWhenUnhealthy: z.boolean(),
109-
ammTxs: z.boolean().default(false),
110-
dataDirectory: z.string().optional(),
111-
dataStoreMapSizeKb: z.number().optional(),
112-
})
113-
.transform(config => ({
114-
nodeUrl: undefined,
115-
nodeAdminUrl: undefined,
116-
l1RpcUrls: undefined,
117-
senderSalt: undefined,
118-
l2GasLimit: undefined,
119-
daGasLimit: undefined,
120-
l1Mnemonic: undefined,
121-
l1PrivateKey: undefined,
122-
senderPrivateKey: undefined,
123-
dataDirectory: undefined,
124-
dataStoreMapSizeKb: 1_024 * 1_024,
125-
...config,
126-
})) satisfies ZodFor<BotConfig>;
83+
export const BotConfigSchema = zodFor<BotConfig>()(
84+
z
85+
.object({
86+
nodeUrl: z.string().optional(),
87+
nodeAdminUrl: z.string().optional(),
88+
l1RpcUrls: z.array(z.string()).optional(),
89+
l1Mnemonic: schemas.SecretValue(z.string()).optional(),
90+
l1PrivateKey: schemas.SecretValue(z.string()).optional(),
91+
l1ToL2MessageTimeoutSeconds: z.number(),
92+
senderPrivateKey: schemas.SecretValue(schemas.Fr).optional(),
93+
senderSalt: schemas.Fr.optional(),
94+
tokenSalt: schemas.Fr,
95+
txIntervalSeconds: z.number(),
96+
privateTransfersPerTx: z.number().int().nonnegative(),
97+
publicTransfersPerTx: z.number().int().nonnegative(),
98+
feePaymentMethod: z.literal('fee_juice'),
99+
baseFeePadding: z.number().int().nonnegative(),
100+
noStart: z.boolean(),
101+
txMinedWaitSeconds: z.number(),
102+
followChain: z.enum(BotFollowChain),
103+
maxPendingTxs: z.number().int().nonnegative(),
104+
flushSetupTransactions: z.boolean(),
105+
l2GasLimit: z.number().int().nonnegative().optional(),
106+
daGasLimit: z.number().int().nonnegative().optional(),
107+
contract: z.nativeEnum(SupportedTokenContracts),
108+
maxConsecutiveErrors: z.number().int().nonnegative(),
109+
stopWhenUnhealthy: z.boolean(),
110+
ammTxs: z.boolean().default(false),
111+
dataDirectory: z.string().optional(),
112+
dataStoreMapSizeKb: z.number().optional(),
113+
})
114+
.transform(config => ({
115+
nodeUrl: undefined,
116+
nodeAdminUrl: undefined,
117+
l1RpcUrls: undefined,
118+
senderSalt: undefined,
119+
l2GasLimit: undefined,
120+
daGasLimit: undefined,
121+
l1Mnemonic: undefined,
122+
l1PrivateKey: undefined,
123+
senderPrivateKey: undefined,
124+
dataDirectory: undefined,
125+
dataStoreMapSizeKb: 1_024 * 1_024,
126+
...config,
127+
})),
128+
);
127129

128130
export const botConfigMappings: ConfigMappingsType<BotConfig> = {
129131
nodeUrl: {

yarn-project/ethereum/src/l1_contract_addresses.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ConfigMappingsType } from '@aztec/foundation/config';
22
import { EthAddress } from '@aztec/foundation/eth-address';
3-
import { type ZodFor, schemas } from '@aztec/foundation/schemas';
3+
import { schemas, zodFor } from '@aztec/foundation/schemas';
44

55
import { z } from 'zod';
66

@@ -35,25 +35,27 @@ export type L1ContractAddresses = {
3535
dateGatedRelayerAddress?: EthAddress | undefined;
3636
};
3737

38-
export const L1ContractAddressesSchema = z.object({
39-
rollupAddress: schemas.EthAddress,
40-
registryAddress: schemas.EthAddress,
41-
inboxAddress: schemas.EthAddress,
42-
outboxAddress: schemas.EthAddress,
43-
feeJuiceAddress: schemas.EthAddress,
44-
stakingAssetAddress: schemas.EthAddress,
45-
feeJuicePortalAddress: schemas.EthAddress,
46-
coinIssuerAddress: schemas.EthAddress,
47-
rewardDistributorAddress: schemas.EthAddress,
48-
governanceProposerAddress: schemas.EthAddress,
49-
governanceAddress: schemas.EthAddress,
50-
slashFactoryAddress: schemas.EthAddress.optional(),
51-
feeAssetHandlerAddress: schemas.EthAddress.optional(),
52-
stakingAssetHandlerAddress: schemas.EthAddress.optional(),
53-
zkPassportVerifierAddress: schemas.EthAddress.optional(),
54-
gseAddress: schemas.EthAddress.optional(),
55-
dateGatedRelayerAddress: schemas.EthAddress.optional(),
56-
}) satisfies ZodFor<L1ContractAddresses>;
38+
export const L1ContractAddressesSchema = zodFor<L1ContractAddresses>()(
39+
z.object({
40+
rollupAddress: schemas.EthAddress,
41+
registryAddress: schemas.EthAddress,
42+
inboxAddress: schemas.EthAddress,
43+
outboxAddress: schemas.EthAddress,
44+
feeJuiceAddress: schemas.EthAddress,
45+
stakingAssetAddress: schemas.EthAddress,
46+
feeJuicePortalAddress: schemas.EthAddress,
47+
coinIssuerAddress: schemas.EthAddress,
48+
rewardDistributorAddress: schemas.EthAddress,
49+
governanceProposerAddress: schemas.EthAddress,
50+
governanceAddress: schemas.EthAddress,
51+
slashFactoryAddress: schemas.EthAddress.optional(),
52+
feeAssetHandlerAddress: schemas.EthAddress.optional(),
53+
stakingAssetHandlerAddress: schemas.EthAddress.optional(),
54+
zkPassportVerifierAddress: schemas.EthAddress.optional(),
55+
gseAddress: schemas.EthAddress.optional(),
56+
dateGatedRelayerAddress: schemas.EthAddress.optional(),
57+
}),
58+
);
5759

5860
const parseEnv = (val: string) => EthAddress.fromString(val);
5961

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,36 @@
11
import type { ZodType } from 'zod';
22

33
export type ZodFor<T> = ZodType<T, any, any>;
4+
5+
/**
6+
* Creates a schema validator that enforces all properties of type T are present in the schema.
7+
* This provides compile-time safety to ensure schemas don't miss optional properties.
8+
*
9+
* @example
10+
* ```ts
11+
* interface MyConfig {
12+
* foo: string;
13+
* bar?: number;
14+
* }
15+
*
16+
* // ✅ This will work - all keys present
17+
* const schema1 = zodFor<MyConfig>()(z.object({
18+
* foo: z.string(),
19+
* bar: z.number().optional(),
20+
* }));
21+
*
22+
* // ❌ This will error - 'bar' is missing
23+
* const schema2 = zodFor<MyConfig>()(z.object({
24+
* foo: z.string(),
25+
* }));
26+
* ```
27+
*/
28+
export function zodFor<T>() {
29+
return (schema => schema) as <S extends ZodType<any, any, any>>(
30+
schema: keyof T extends keyof S['_output']
31+
? keyof S['_output'] extends keyof T
32+
? S
33+
: S & { __error__: 'Schema has extra keys not in type'; __extra__: Exclude<keyof S['_output'], keyof T> }
34+
: S & { __error__: 'Schema is missing keys from type'; __missing__: Exclude<keyof T, keyof S['_output']> },
35+
) => S;
36+
}

0 commit comments

Comments
 (0)