Skip to main content
A program’s functions are main and any helpers it declares. A helper is a top-level function declaration with annotated parameters and, where it returns something, an annotated return type.
helpers.sauce.ts

Declarations

  • Functions are declared at the top level, never inside another function or a block.
  • Functions may call each other in any order, and may recurse.
  • Arrow functions and function expressions are rejected, except as the handler of .catch(...) (see Errors and reverts) and as the closure of program(...).
  • Default parameters, rest parameters and destructured parameters are rejected.
  • require, revert, log and eval are reserved names, and so are the type names Uint256 and Int256 (Types).
  • A call names a declared function and passes one argument per parameter.
Breaking one of these rules is refused:
recursion.sauce.ts
factorial(n) reverts once the product passes 2²⁵⁶ − 1, because * is checked.

main

main is the entry point. Its parameters are the program’s inputs and its return value is what the cook returns; see Layout of a program. Code cannot call it: return main(a - 1); fails with “the entry point (main) cannot be called”. Put the recursive logic in a helper, as factorial above does.

Parameters and annotations

A parameter without an annotation is a Uint256. Passing a bool to one, as in pick(useAmount, amount) with function pick(flag, amount: Uint256), fails at the call site with “a function argument: expected a number, found a bool”. Annotate the parameter: flag: bool. A function other than main has a fixed budget for its parameters and the values its body holds at once: about sixteen. A helper with sixteen parameters that reads each once compiles. Seventeen parameters with the first read twice do not: the compile fails with “sum keeps too many values in use at once — split it into smaller functions, take fewer parameters, or build a large literal from fewer variables at a time”. Pass a record or an array instead of a long list of numbers. main’s parameters are not limited this way.

Return types

A comparison is a bool, but a function with no return annotation returns a number, 1 or 0. if, while and ! accept that number, but a : bool function cannot return it: return isLarge(amount); in a : bool main, where isLarge has no annotation, fails with “a return value: expected a bool, found a number”. Give a helper that answers yes or no a : bool return type:
annotated-bool.sauce.ts
A helper that returns nothing is declared : void. Without the annotation it is expected to return a number, and the compile fails with “pay does not return on every path”. The result of a : void call is not a value: const result = check(amount); fails with “a call to a : void function produces no value, so its result can’t be used as one — call it as a statement instead”.
void-helpers.sauce.ts
A helper that returns bytes, an array or a record needs its return type. Without it, return amount.toBytesBe(32); fails with “returning a bytes value from a function other than main needs a return-type annotation”. Write : bytes:
bytes-return.sauce.ts
A helper that returns an Int256 needs : Int256. Without it, the compiler says “a helper returning a signed integer needs an explicit : Int256 return annotation”. With the annotation:
signed-helper-fixed.sauce.ts
On a local fork of Base at block 52965000 with the current contracts, before = 10 and after = 7 return -3.

Return on every path

A helper that returns a value returns it on every path. An if with a return and no else, or a loop that can end without one, is refused: larger with only if (a > b) { return a; } fails with “larger does not return on every path”. End the helper with a return:
with-return.sauce.ts

Arguments: by reference and by copy

Numbers, bools, addresses and bytes are copied into a helper. Arrays and records are passed by reference: a helper that writes xs[i] = v or r.field = v changes the caller’s value.
by-reference.sauce.ts
On a local fork of Base at block 52965000 with the current contracts, this returns 167: xs[0] became 1 and order.amount 6, while n stayed 7. A helper fills an array it is given, but cannot replace it with a new array. xs = [1, 2]; in a helper with xs: Uint256[] fails with “xs cannot be reassigned a value of a different type — expected an element count known only at run time, found 2 element(s)”. Write the elements instead: xs[0] = 1;.

A script’s constants are not visible to helpers

In a top-level script, a top-level const is a local of the script’s entry. A helper cannot read it: return x > limit; in a helper, with const limit = 5; at the top of the script, fails with “limit is not a declared variable”. Pass the value as an argument, or declare main so that const limit = 5; becomes a module constant every function reads:
script-scope.sauce.ts

Compile-time specialisation

The compiler evaluates what it knows when it compiles, through records and helper calls as well as constants:
  • A record built from an object literal keeps its known fields as constants, and a condition on them is decided when compiling.
  • A helper called with a known argument is compiled for that argument: a branch the argument rules out is removed.
Both programs below compile, for Base, Ethereum and Solana, to the same bytes as a main written without the record or the helper:
With tier a main parameter instead, feeFor(tier, amount) keeps both branches and the call: the program is 83 bytes on Base against 43. Expressions and control flow covers constants and tests of a registry name such as if (UniswapV3.Factory). A helper is checked as if it ran: a registry name the chain lacks, used in a helper, must be tested in that helper, or the program is refused on that chain.

Programs built from a closure

program(closure, ...args) compiles an arrow function as a separate program, with one argument after the closure per closure parameter. It returns a record { code, args }: code is that program’s bytecode, and args the encoded run-time values it takes. eval(code.concat(args)) runs the program read-only and returns what it returns, as bytes.
program.sauce.ts
It compiles for Base, Ethereum and Solana. With delta = -10, it returns the 32-byte word for -5, 0xff…fb: on a local fork of Base at block 52965000 with the current contracts, and in the sauce command’s local copy (LiteSVM) of Solana at slot 460264641. The closure reads delta, a variable of main. That is a capture: the value is passed to the new program, which can read it and cannot change it. Here nested.args is the 32-byte word of delta, and the 2, known when compiling, is in nested.code. Without the capture (7 / divisor), nested.args is empty. Both were read on the same fork.
  • A constant the closure reads is compiled into its code: with const FEE = 30; at module level, (x: Uint256): Uint256 => x * FEE compiles to the same bytes as x * 30, for Base, Ethereum and Solana.
  • A capture is read-only. Assigning to it in the closure (amount = amount + fee;) is refused with “amount belongs to the program around this closure: the closure’s program reads it, and can neither assign it nor change what it holds”. Compute the new value in the closure and return it.
  • A run-time bytes value cannot be captured. Reading data.length in a closure, where data is a bytes parameter of main, is refused with “data is a run-time bytes of the program around this closure, which the closure’s program cannot read: it reads a number, an address or a bool, and the values the compiler knows”. Pass what the closure needs, such as data.length, as an argument after the closure.

What a call costs

On an EVM chain, a helper is either compiled into its caller or called in a call frame of its own. A helper whose body is straight-line arithmetic, assignments and one return, with no if, loop or contract call, is compiled into its caller where that keeps the program small. Every other helper keeps its frame. Simulated on Base at block 52997759, each program ran a loop of 100 iterations over main’s run-time arguments, and each helper is measured against the same code written inline: A helper compiled into its caller cost at most 113 gas a call more than the code written out, and one cost less. A helper with its own frame cost 342 to 405 gas a call more. A helper called with known arguments can be compiled away (above). @sauce/router keeps each exported function self-contained: its functions branch and call contracts, so a helper inside one would keep its frame. Call what already exists directly: USDC.transfer(to, amount), UniswapV3.Factory.getPool(WETH, USDC, 500), a @sauce/router function. A helper that wraps a single contract call keeps its frame, 405 gas a call in the table, and adds nothing else. Keep a helper for logic of its own that the program uses more than once.

Library functions

@sauce/token and @sauce/router export functions that a program imports by name and calls like its own:
library-calls.sauce.ts
USDC.transfer(to, amount) and Token(address).transfer(to, amount) are calls to the same @sauce/token function, so a token write has one implementation however it is spelled. @sauce/token lists its functions.

Next steps