Skip to content

[FEATURE] Type simulate()'s return value from the artifact instead of returning any #25299

Description

@shiqicao

Summary

ContractFunctionInteraction.simulate() returns a result typed any:

export type SimulationResult = {
  /** Return value of the function */
  result: any;
  ...
} & OffchainOutput;

so every read is a hand-written cast:

const balance = (
  await token.methods.balance_of(owner).simulate({ from })
).result as bigint;

const { storage, len } = (
  await registry.methods.entries().simulate({ from: NO_FROM })
).result as { storage: AztecAddress[]; len: bigint };

The return type is in the artifact — FunctionAbi.returnTypes — and aztec codegen already reads
it. It simply isn't carried into the TypeScript types.

The asymmetry

Codegen already maps Noir ABI types to TypeScript for parameters:

// generated
burn: ((from: AztecAddressLike, amount: (bigint | number), _nonce: FieldLike)
  => ContractFunctionInteraction) & Pick<ContractMethod, 'selector'>;

AztecAddressLike, FieldLike, bigint | number — the mapping exists and is applied. The same
mapping is not applied to the return value, so the typed wrapper hands back an untyped result.

Why any specifically is a problem

any does not merely fail to help, it actively removes checking:

  • A wrong cast is not caught. as bigint on a function returning a struct compiles, and fails
    later as a runtime type error far from the call.
  • A missing cast is not caught either. const x = (await ...).simulate()).result; type-checks
    and silently infects everything downstream with any.
  • Renaming or re-typing a Noir return value cannot break the build. The compiler has nothing to
    compare against, so drift surfaces at runtime.

unknown would at least force the cast to be deliberate, but the artifact makes the real type
available, so neither is necessary.

Request

Make the interaction generic in its return type, so the generated wrapper can supply it:

token.methods.balance_of(owner)      // ContractFunctionInteraction<bigint>
  .simulate({ from })                // Promise<SimulationResult<bigint>>
  .then(r => r.result);              // bigint — no cast

with SimulationResult<T> = { result: T; ... } and codegen deriving T from returnTypes using
the mapping it already applies to parameters. Functions returning nothing become
ContractFunctionInteraction<void>.

Backwards compatibility can default the parameter to the current behaviour
(ContractFunctionInteraction<T = any>), so untyped construction paths keep working while
generated wrappers become precise.

Who benefits

Every consumer of a generated contract wrapper. In one mid-sized codebase we have 67 simulate
call sites, 48 of which carry a hand-written cast — each one a place where the ABI is restated by
hand and nothing checks it.

Environment

aztec 5.0.1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions