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 suffixncompiles to the same integer; write integers without it. ~0is the maximumUint256, 2²⁵⁶ − 1.1e18and1.5are rejected: the compile fails with “1e18is not a valid 256-bit literal”. Write10 ** 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 - 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’stick; - the result of
Math.signExtend(value, byte), which reads the lowbyte + 1bytes ofvalueas a two’s-complement integer:Math.signExtend(0xfffff6, 2)is-10.
const delta: Int256 = -10;. A parameter or a return type can be Int256 too, and sauce simulate prints the value signed:
int256.sauce.ts
--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 fitUint256, 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
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
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
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. AnintN 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
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
[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
AnAddress 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
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
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 ofnzeros, to fill by index. There is nopush.[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.lengthis 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. Amainparameter takes at most 4 levels.- One caller-supplied collection in
main’s arguments is limited to 16 KiB of elements: aUint256[]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, aUint256[]argument of 512 elements was accepted and one of 513 reverted with no data, withmainonly returning its length. On Solana 513 elements are refused too, but one transaction’s 1,400,000 compute units run out first: simulated with thesaucecommand’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. Wheremaindoes 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
constbound to one) evaluates only the element it takes:function main(zero: Uint256): Uint256 { return [1 / zero, 7][1]; }returns7whenzerois0, wherereturn 1 / zero;reverts, and an overflow in the unread element ([big * 2, 7][1], withbigaUint256of 2²⁵⁵ or more) returns7too. Bound to a variable first (const a = [1 / zero, 7]; return a[1];), indexed at run time, at aletor at a computed position such as0 + 1, the division reverts, as does an unusedconst 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 returns7. - 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 forzero = 0, and a write insidefruns 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]returnsf(1). A builtin such asMath.mulDivis not a call. Simulated on a local fork of Base at block 53166500 with the current contracts. Use an explicitrequirefor 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]is7(simulated on the fork at block 52965000).
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 aninterface 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 amainparameter, an array element, a contract call,abi.decodeor a function’s return value is read by position. A position is a number literal or aconstbound 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]onconst 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”. Writeorder.amount. -
A position that is not a literal or a bound
constis refused on a record that comes from a parameter:order[i]fails with “a tuple index must be a number literal or aconstbound to one”. Read the field by name, or branch onibetween 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] = recorddestructures by position, and works on an object literal too. See Expressions and control flow. - A record is a reference, like an array.
UniswapV3.Pool(pool).slot0().sqrtPriceX96.
Accounts
On Solana, a program reads and writes the accounts its transaction attaches. Each is amain parameter of type Account, optionally with the roles the transaction must give it:
accounts.solana.sauce.ts
acct.addressis the account’s public key,acct.ownerthe program that owns it,acct.dataLenits 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.sstorewrites only an account declaredAccount<Writable, Signer>.
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
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:
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
: Int256return annotation. - Builtins:
Math,crypto,abiand every value method. - EVM and Solana: accounts and what differs between targets.
