Skip to main content
SauceScript is statically typed. Every value has one of the types below, fixed when the program compiles, and a variable keeps the type of its first value. Using a value of the wrong type is a compile error.

Integers

SauceScript has two integer types, both 256 bits wide: Uint256, unsigned, from 0 to 2²⁵⁶ − 1, and Int256, signed. An unannotated integer is a Uint256. There are no fractions, so an amount is written in the token’s smallest unit: USDC has 6 decimals, so 1 USDC is 1000000, and 1 ETH is 10 ** 18 wei.
integers.sauce.ts
  • Integers are written as decimal (3000), hexadecimal (0xff) or with _ separators (1_000_000). Each literal is read from its text as a 256-bit integer, so a value above 2⁵³ is exact. TypeScript’s bigint suffix n compiles to the same integer; write integers without it.
  • ~0 is the maximum Uint256, 2²⁵⁶ − 1.
  • 1e18 and 1.5 are rejected: the compile fails with “1e18 is not a valid 256-bit literal”. Write 10 ** 18, as in the program above, or the amount in the smallest unit.

Checked arithmetic

+, -, *, /, % and ** are checked. An overflow, an underflow or a division by zero reverts the transaction, with one exception: an array literal indexed at a literal position (or at a const bound to one) skips an unread element when no call or token read is at or after it. With no helper call, contract call or token read in the literal, only the element taken is evaluated, so [1 / zero, 7][1] returns 7 for zero 0. With one, every element up to and including the last call or token read runs, in order: [1 / zero, pot.USDC][1] reverts, and [pot.USDC, 1 / zero][0] returns the Pot’s USDC. A builtin such as Math.mulDiv is not a call: [Math.mulDiv(1, 1, zero), 7][1] returns 7 (Arrays). An object literal that has no call, read by field where it is written, as in ({ a: 1 / zero, b: 7 }).b, behaves the same. When every operand is a constant, the compiler evaluates the expression and refuses it instead: return 0 - 1; in a function that returns Uint256 fails with “constant subtraction underflows Uint256; for a negative result, convert an operand before subtracting with Int256(value), or give the literal expression a : Int256 binding or return annotation”. With an Int256 return type the same expression is -1:
underflow-signed.sauce.ts
A run-time a - b with b > a reverts. On a local fork of Base at block 52965000 with the current contracts, a = 1 and b = 2 revert with no reason data. Errors and reverts covers how a revert surfaces.

Int256

Int256 holds a signed value from −2²⁵⁵ to 2²⁵⁵ − 1. A value is an Int256 when it is:
  • a negative literal, such as -10;
  • a contract output that the ABI declares intN (int24, int128, int256), such as a pool’s tick;
  • the result of Math.signExtend(value, byte), which reads the low byte + 1 bytes of value as a two’s-complement integer: Math.signExtend(0xfffff6, 2) is -10.
An annotation names the type: const delta: Int256 = -10;. A parameter or a return type can be Int256 too, and sauce simulate prints the value signed:
int256.sauce.ts
On a local fork of Base at block 52965000 with the current contracts, --args -100 prints main returns Int256: -198657; the pool’s own tick is −198557. On Int256, the operators are signed: / truncates toward zero, % takes the sign of the dividend, >> copies the sign bit, and <, >, <= and >= order negative values below positive ones. Expressions and control flow lists each rule with the values it was simulated on. Int256.fromBits(word) reinterprets a Uint256 as signed; the from-bits.sauce.ts program below divides one, Int256.fromBits(word) / 2. Dividing the minimum, −2²⁵⁵, by -1 has no result, so it reverts.

A negative literal is never a Uint256

A negative constant does not fit Uint256, and the compiler says so. const delta: Uint256 = -10; fails with “constant Int256 value is outside Uint256’s range; conversions check bounds, so choose a representable value or a type that includes it”. Write const delta: Int256 = -10;. A negative value held in an Int256 that is converted to Uint256 at run time reverts instead (see below).

Converting between the kinds

Int256(x) and Uint256(x) convert a value, and both check it: Int256(x) reverts when x is above 2²⁵⁵ − 1, and Uint256(x) reverts when x is negative. The program below adds a signed delta to an unsigned balance:
conversions.sauce.ts
On a local fork of Base at block 52965000 with the current contracts: Int256.fromBits(word) and Uint256.fromBits(value) keep the 256 bits and change the type, whatever number they then mean. Use them to read a word you hold as a signed value:
from-bits.sauce.ts
With word equal to 2²⁵⁶ − 10, simulated on the same fork, this returns -5. Without fromBits, Int256(word) would revert, because that word is above 2²⁵⁵ − 1.

Mixed arithmetic

+, -, *, /, % and ** need both operands to be the same kind. A Uint256 and an Int256 in one expression are refused until one is converted: A literal takes the type its context needs, so delta * 3 is an Int256 expression. Comparisons are the exception to the rule above. They compare numeric values across the two kinds, and a negative Int256 is below every Uint256: on the same fork, b < a is true for b = -1 and a = 2²⁵⁶ − 1. To compare a raw word as a signed number, convert it with Int256.fromBits: for word = 2²⁵⁶ − 7, word < 2 is false and Int256.fromBits(word) < 2 is true.

Negation

-x on an Int256 negates it, and reverts for the minimum, −2²⁵⁵, whose negation does not fit. -x on a Uint256 compiles but reverts for any nonzero x, because the result is negative; -0 is 0. To negate an unsigned amount, convert it first:
negate.sauce.ts
On the same fork, amount = 5 returns -5. The program function main(a: Uint256): Uint256 { return -a; } reverts for a = 5 and returns 0 for a = 0.

Reserved names

Uint256 and Int256 are reserved. A parameter, a constant or a function with either name is refused, so rename it. const Int256 = 5; fails with “Int256 is a builtin constructor and cannot be bound — constructor calls resolve against the builtin before any binding is consulted, so pick another name”.

Signed values at a contract boundary

A contract call converts each argument to the ABI type of its parameter and checks it against that type’s width, and does the same to each integer it reads back. An intN parameter takes an Int256, a uintN parameter a Uint256. Narrow integers are checked. UniswapV3.Factory.getPool(a, b, fee) takes a uint24 fee. On a local fork of Base at block 52965000 with the current contracts, fee = 500 returns the WETH/USDC pool and fee = 500 + 2**24 reverts with no data. For an output, UniswapV3.Pool(target).fee() returns 500 for that pool and reverts for an address whose fee() returns a number wider than 24 bits. Pass a signed value to a signed input. UniswapV4.StateView.getTickLiquidity(poolId, tick) takes an int24 tick, and -1 asks for tick −1. The constant ~0 is the bit pattern of −1 and compiles there to the same program as -1. A Uint256 with all bits set keeps its type and is not -1 there. As a constant it is refused: passing the decimal literal 115792089237316195423570985008687907853269984665640564039457584007913129639935 as the tick fails with “constant Uint256 value is outside Int256’s range; conversions check bounds, so choose a representable value or a type that includes it”. Take the tick as an Int256:
signed-input.sauce.ts
As a Uint256 parameter holding 2²⁵⁶ − 1, the call reverts with no data on the same fork, and Int256.fromBits(tick) passes the same bits as -1. Containers keep their kind. An array for an intN[] parameter is an Int256[], and one for uintN[] is a Uint256[]. BalancerV2.Vault.batchSwap takes limits as int256[] and returns the int256[] of asset deltas:
containers.sauce.ts
On the same fork the program returns [0, 0]. A scalar conversion does not retype an array, so a container of the other kind is refused: Builtins lists Int256, Uint256 and Math.signExtend.

Booleans

bool holds true or false. Comparisons and !, && and || produce one. A bool is kept apart from numbers: filled + 1, with filled: bool, fails with “a binary operator: expected a number, found a bool”. Write filled ? 2 : 1 to turn a bool into a number. In the other direction, if, while, !, &&, || and the first operand of ? : accept a number as well as a bool, and treat any nonzero number as true. A function with no return annotation returns a Uint256, even when it returns a comparison: annotate a helper that answers yes or no with : bool, and one that returns a signed value with : Int256. Functions shows the rule.

Addresses

An Address is a number. It compares with == and !=, and with < and > (a pool’s token0 is the lower address). A token symbol and a protocol contract are addresses:
address.sauce.ts
On Solana an address holds a 32-byte public key; on an EVM chain, a 20-byte address. An address the chain’s registry records is written by its name (see Names); any other address is a main parameter.

Bytes and strings

bytes and string are the same type: a byte string. "text" is its UTF-8 bytes, and hex`00ff` raw bytes (an even number of hex digits, an optional 0x, spaces ignored).
bytes.sauce.ts
Little-endian is Solana’s layout: amount.toBytesLe(8) is a u64 field. A method can be called on a literal as well as on a variable:
literal-method.sauce.ts
This compiles for each chain. On a local fork of Base at block 52965000 with the current contracts, data = 0x1234 returns 0x00ff1234. Two bytes values cannot be compared with ==: a == b fails with “a comparison operator requires a scalar value”. Compare their hashes:
bytes-hash.sauce.ts
abi.encode and abi.decode convert between values and ABI bytes on EVM chains. See Builtins.

Arrays

An array holds elements of one type. Its length is fixed when it is created.
arrays.sauce.ts
  • new Array(n) makes an array of n zeros, to fill by index. There is no push.
  • [a, b, c] is an array literal; every element must have the same type. An empty literal needs a declared type: let none: Uint256[] = [];.
  • xs.length is the element count and cannot be assigned. An index past the end reverts: on a local fork of Base at block 52965000 with the current contracts, xs[5] on the two-element argument [1,2] reverts with no data.
  • T[][] nests arrays. A main parameter takes at most 4 levels.
  • One caller-supplied collection in main’s arguments is limited to 16 KiB of elements: a Uint256[] takes up to 512. Oversized input reverts the cook; split a larger batch into separate cooks, each atomic independently. On a local fork of Base at block 53161500 with the current contracts, a Uint256[] argument of 512 elements was accepted and one of 513 reverted with no data, with main only returning its length. On Solana 513 elements are refused too, but one transaction’s 1,400,000 compute units run out first: simulated with the sauce command’s local copy of Solana (LiteSVM) and the current engine and Kitchen, the same length-only program used 1,149,685 compute units for 256 elements, and 384 and 512 elements exhausted the budget. Where main does more than return a length, the usable batch is smaller: simulate the largest batch you will send.
  • Do not use an unread array element as a required validation step. A call-free array literal indexed at a literal position (or at a const bound to one) evaluates only the element it takes: function main(zero: Uint256): Uint256 { return [1 / zero, 7][1]; } returns 7 when zero is 0, where return 1 / zero; reverts, and an overflow in the unread element ([big * 2, 7][1], with big a Uint256 of 2²⁵⁵ or more) returns 7 too. Bound to a variable first (const a = [1 / zero, 7]; return a[1];), indexed at run time, at a let or at a computed position such as 0 + 1, the division reverts, as does an unused const x = 1 / zero;. All were simulated on a local fork of Base at block 53161500 with the current contracts; on Solana [1 / zero, 7][1] also returns 7.
  • A call in the literal changes that. If a literal contains a helper call, a contract call or a token read (pot.USDC, USDC.balanceOf(pot)), every element up to and including the last one runs, in order, even when indexing takes another element. [1 / zero, f()][1] and [f(1 / zero), 7][1] both revert for zero = 0, and a write inside f runs when execution reaches it. Elements after the last call are still skipped: [pot.USDC, 1 / zero][0] returns the Pot’s USDC, and [f(1), 1 / zero][0] returns f(1). A builtin such as Math.mulDiv is not a call. Simulated on a local fork of Base at block 53166500 with the current contracts. Use an explicit require for a condition that must always be checked, and a statement of its own for a call that must run.
  • An array is a reference. Assigning it to another variable, or passing it to a function, shares the same elements: after const b = a; b[0] = 7;, a[0] is 7 (simulated on the fork at block 52965000).
A module-level const array is a lookup table, read only at a literal index. See Layout of a program.

Records

A record holds named fields, each with its own type. Its type is an interface or an inline object type:
records.sauce.ts
  • { amount, recipient: to } builds a record; a field named like a variable can be written once (amount).
  • A field is read by name: order.amount. This works on every record.
  • A field is also read by a constant position, legs[0][1] for the second field of the first leg, but only on a record the compiler does not hold. A record that comes from a main parameter, an array element, a contract call, abi.decode or a function’s return value is read by position. A position is a number literal or a const bound to one:
positions.sauce.ts
  • An object literal bound to a variable in the same function is held by the compiler under its field names. Reading it by position is refused, even when some fields are run-time values: order[0] on const order = { amount: amount, fee: 30 }; fails with “a computed member of an object the compiler holds (read it by name: object.member) is not supported in SauceScript yet”. Write order.amount.
  • A position that is not a literal or a bound const is refused on a record that comes from a parameter: order[i] fails with “a tuple index must be a number literal or a const bound to one”. Read the field by name, or branch on i between literal positions: i == 0 ? order[0] : order[1]. A computed position on an object literal is refused with the message of the previous item.
  • const [a, b] = record destructures by position, and works on an object literal too. See Expressions and control flow.
  • A record is a reference, like an array.
A contract call with several outputs returns a record whose fields are named after the ABI’s outputs: UniswapV3.Pool(pool).slot0().sqrtPriceX96.

Accounts

On Solana, a program reads and writes the accounts its transaction attaches. Each is a main parameter of type Account, optionally with the roles the transaction must give it:
accounts.solana.sauce.ts
  • acct.address is the account’s public key, acct.owner the program that owns it, acct.dataLen its data size in bytes. They are read-only.
  • Accounts compare with == and != only.
  • An account is not data. It cannot be returned, and arithmetic on it fails:
account-key.solana.sauce.ts
  • svm.sstore writes only an account declared Account<Writable, Signer>.
On an EVM chain, a bare Account compiles as an address, and acct.address is that address. A role is refused: function main(target: Account<Writable>) {} fails with “an Account role is not available on the evm target; an Account is an address value on the EVM — write Account alone”. Write the bare type:
account-evm.sauce.ts
EVM and Solana describes how accounts are attached.

Type names that are not types

A Solidity or JavaScript type name is refused with the list of valid ones. function main(amount: uint256) {} fails with:
Write amount: Uint256. Union types (Uint256 | Address), Array<T> and tuple types ([Uint256, Address]) are refused the same way.

Not supported

Each of these fails the compile. A type alias such as type Order = { amount: Uint256 }; is reported as “a type alias declaration (an interface names a record type) is not supported in SauceScript”; declare an interface, as records.sauce.ts does. An as cast such as value as Address is reported as “an as cast is not supported in SauceScript”. An address is already a number, so returning value from a function declared : Address compiles.

Next steps

  • Expressions and control flow: operators on these types, signed arithmetic, loops and destructuring.
  • Functions: passing values by reference and by copy, and the : Int256 return annotation.
  • Builtins: Math, crypto, abi and every value method.
  • EVM and Solana: accounts and what differs between targets.