Skip to main content
A program refers to tokens, contracts and chains by name. The SDK resolves every name for the chain the program is compiled for, from the registries that ship in the package, before the compiler sees the program. Names are program syntax, not JavaScript values: a program is compiled, never run as TypeScript.

How a name resolves

resolve.sauce.ts
sauce compile resolve.sauce.ts --chain base writes Base’s USDC address into the bytecode. Compiling the same file with --chain ethereum or --chain solana writes that chain’s USDC. One file serves every chain whose names it uses, and each compile produces its own bytecode.
  • Resolution is per chain and happens at compile time. Nothing of the name is left in the bytecode; the engine on chain only reads addresses.
  • sauce compile --json lists what was applied. defines holds each token and chainId with its value, and contracts holds each namespace contract with its address (addresses abbreviated here):
  • A use of a name the chain lacks is a compile error at the use, naming the chain. The error gives the file, line and column. Nothing is compiled, so nothing can be sent. This program compiles for Ethereum, which registers USDT:
    missing-token.sauce.ts
    Base does not register USDT, so --chain base fails like this at USDT.transfer:
  • A protocol contract the chain lacks is refused the same way, with the chains that have it. This program compiles for Base:
    missing-contract.sauce.ts
    Compiled with --chain unichain, it fails:
    To use a name only on the chains that have it, test it: Using a name only where the chain has it.
A registry row records where a contract was deployed. It does not show that the contract still has code, that its owner has not paused it, or that a pool has liquidity today. A compile proves a name is in the registry; sauce simulate proves a call works on the chain as it is now. Chains, Protocols and Tokens list every name per chain. sauce globals --chain <chain> prints the same registries for one chain, with --tokens, --protocols, --protocol <Namespace> for a namespace’s methods, --stocks, and --search <text>. See sauce globals.

Token symbols

A token symbol is that chain’s token address. It is a value (balanceOf(USDC, account)) and the receiver of the token methods (USDC.transfer(…)).
  • Symbols are case-sensitive. SPCXC is not SPCXc.
  • Not every chain registers symbols. Only 19 of the 46 registry chains register any: Ethereum, Optimism, Unichain, Polygon, Monad, Sei, Mantle, Arc, Base, Plasma, Arbitrum, Ink, Berachain and Solana, and the testnets Arc Testnet, Base Sepolia, Ethereum Sepolia, Solana Devnet and Solana Testnet. Among those, the symbols differ: Base has USDC and WETH, Ethereum adds USDT, Unichain has only USDT0. On a chain that does not register a symbol, take the address as a main parameter and use Token(address).
  • Tokenized stocks are tokens. Each issuer’s own ticker is the symbol, with . written as _ (BRK.Bx is BRK_Bx). They take the same methods, balances and swaps as any token:
    stock.sauce.ts
    The chain’s stocks are not in its sauce globals token list, which counts them. sauce globals --chain base --stocks lists them and --search spacex finds one by symbol, company or ticker. One company has one token per issuer, so the symbol depends on the chain and the issuer.
  • A wrong case is refused, with the right one suggested. On Base, SPCXC.transfer(to, amount) fails with this hint; write SPCXc, as stock.sauce.ts does:

Balances

<holder>.<TOKEN> reads a holder’s balance of a token. It is TOKEN.balanceOf(holder) written short and compiles to the same bytes.
balances.sauce.ts
  • pot is the Pot, and pot.USDC is the Pot’s USDC.
  • The native coin is .balance. pot.balance is ctx.selfBalance(), and account.balance is ctx.balance(account). On Solana pot.balance is the Pot’s lamports. .<SYMBOL> reads only a token the chain registers.
  • A holder on an EVM chain is any address: pot or ctx.self(), a variable or parameter, a protocol contract (UniswapV4.PoolManager), a contract at an address (UniswapV3.Pool(pool)), a call that returns an address, or a token contract (WETH.balance is the ETH locked in WETH).
  • A holder on Solana must be known when the transaction is built, because its account is added to main: pot, pot.owner, an Address parameter of main, a token’s mint, or a 32-byte key written out.
  • .balance on a value that is not an address reads a field: a record, a list, a value abi.decode returns, or a struct a contract call returns. For the native coin of an address such a value holds, write ctx.balance(x).
Two things are refused as a holder: a record or list, because a property named like a token is reserved for this form; and an interface with no address. Token(address) has no short form: its balance is Token(address).balanceOf(holder).

The Pot’s owner and account names

pot.owner is the Pot’s owner on both targets. On EVM it reads the Pot’s owner(); a Shared Pot returns the zero address. On Solana it is the owner’s account, which signs the cook. pot.owner.USDC and pot.owner.balance read the owner’s token and native-coin balances. On Solana, reading only pot.owner does not bind a Pot and permits --via engine. Use Account(key) for an account at a key, Account.pda(program, [seeds]) for a PDA, and Account.systemProgram, Account.tokenProgram, Account.token2022Program, Account.associatedTokenProgram or Account.memoProgram for standard programs. These accounts are read-only unless given a role: pot.owner.writable().signer(). An account’s .owner is its owning program. To read the program that owns the Pot account, write Account(pot.address).owner. A declaration or import of pot or Account shadows that global in its scope; a compile-time binding also takes precedence. Account in a type position remains the account type. The Pot’s owner, balance and token-balance members are read-only; store values you need to change in your own locals. ctx.self(), ctx.balance(x) and ctx.selfBalance() remain valid.

Arc: one balance, two scales

On Arc the native coin is USDC, and the ERC-20 USDC is the same balance at another scale: 18 decimals for the native coin, 6 for the token. pot.USDC reads the token and pot.balance the native coin:
arc-balances.sauce.ts
pot.USDC is the native balance divided by 10^12, rounded down, so less than 10^12 wei reads as 0. Convert one amount to the other’s scale before comparing them. EVM and Solana lists the native coins.

Token(address) and bound tokens

A token the registry does not carry is Token(address), with the address as a main parameter:
token-address.sauce.ts
A Token(…) can be the receiver of a call, or be bound once to a local that is only ever called:
  • the binding is declared once with const, in the whole program;
  • it is never reassigned;
  • it is used only as t.method(…).
Any other use is refused, because passing it on would reach the unchecked ABI write instead of the checked one. Passing t to a function, as in show(t), fails with:
Pass the address instead, as token-address.sauce.ts passes token, and bind Token(token) where it is called. On Solana, Token(mint) takes the mint as an Address argument. Its token program is read from the cluster when the cook is built, and a mint whose program cannot be read is refused. See EVM and Solana.

Token methods

  • A token that returns nothing, such as USDT, works when the address has code. A false return, a malformed return, or an address with no code reverts the program.
  • A write returns nothing, so its result cannot be used. const ok = USDC.transfer(to, amount); fails with the message below; call the write as a statement, as token-functions.sauce.ts does:
  • Approve the amount the next call spends and no more. An allowance that the program leaves standing outlives the program.
The methods compile to the @sauce/token functions of the same names, which lists every function and what each one checks.

The same functions without a method

@sauce/token’s functions need no import. They take the token as the first argument:
token-functions.sauce.ts

Protocol namespaces

Every catalogued protocol has a namespace, in PascalCase (uniswap-v3 is UniswapV3), resolved for the compiled-for chain with its addresses and ABIs from the registry. A member is one of three kinds:
namespaces.sauce.ts
  • An interface is called with an address in the same expression: UniswapV3.Pool(pool).slot0(). UniswapV3.Pool.at(pool) is the same. A deployed contract’s name is never called with an address; .at(address) reaches another deployment of its ABI.
  • A contract a call returns is called directly where the registry records what the returned address is: found.liquidity() above, and Erc4626.Vault(vault).asset().decimals(). Contracts and ABIs covers calls.
  • A namespace’s default contract is called by the namespace alone when the namespace has exactly one deployed contract with an ABI. AaveV3.supply(…) calls AaveV3.Pool.supply(…). A namespace with several contracts, such as UniswapV3, needs the contract’s name.
  • A family alias groups a protocol’s versions. Uniswap covers UniswapV2, UniswapV3 and UniswapV4, and Aave covers AaveV2, AaveV3 and AaveV4. An alias names a contract only when exactly one member has it on the chain.
    alias.sauce.ts
    Uniswap.Factory exists in V2 and V3, so it is ambiguous. return Uniswap.Factory; fails with the message below; name the version, UniswapV3.Factory, as namespaces.sauce.ts does:
  • A chain-qualified contract is another chain’s address and nothing more. A chain name qualifies the namespace:
    qualified.sauce.ts
    The program still runs on the chain it is compiled for. A call through the qualified name is a call to that address on this chain, where it is some other contract or none, and the compiler does not stop it. Pass a qualified name to something that acts on the other chain; never call it. A token is never qualified: the symbol is always the compiled-for chain’s.

Using a name only where the chain has it

Every registry name is declared on every chain: each protocol namespace with every contract it has on any chain, family aliases included, and each token symbol some chain registers. Where the chain the program is compiled for lacks a contract or a token, the name is zero. A test of the name is decided when the program compiles, and the branch the test rules out is not compiled, so one program can name a venue or a token that some of its chains lack:
presence.sauce.ts
This compiles for Base, Ethereum and Solana. On Base it pays in USDC; on Solana, which has no Uniswap V3 factory, it compiles without the pool lookup.
  • A test is the name in a condition: if (X), !X, X != 0, X == 0, X ? a : b, X && … and X || …. X is a contract (UniswapV3.Factory, Uniswap.UniversalRouter, or one on a named chain, Arbitrum.UniswapV3.Factory) or a token symbol (USDT). A guard that returns, if (!USDT) return 0;, leaves out the rest of its block where USDT is absent.
  • Any other use of a name the chain lacks is refused at the use, naming the chain: a call on it, passing it, returning it, or const a = X. A zero never reaches the chain as a contract address. require(X != 0) on a chain without X is refused too, since it fails whatever the program does.
  • A decision is made only from registry names, true and false, !, &&, || and comparisons with 0. X > 0 is a use of X, not a test. A decision held in a variable is live code: with const has = USDT != 0; if (has) { USDT.transfer(to, amount); }, Base refuses the transfer. A helper function is checked as if it ran, so it tests the name itself.
  • Outside a condition, X && Y and X || Y give true or false, and each side is a test: const both = USDT && UniswapV3.Factory; compiles on Base, where USDT is absent. On a contract they give a bool too: const factory = UniswapV3.Factory; return factory || 0; returns true, not the factory’s address.
  • A name no chain has is an error on every chain, tested or not, so a typo cannot drop a branch. if (UniswapV9.Factory) { … } fails on Base with “UniswapV9 is not a token or protocol on base. Did you mean UniswapV2 or UniswapV3 or UniswapV4? …”, and the same holds for a contract or method the registry does not have.
  • A program’s own declaration wins. A local, parameter or function named like a registry name is the program’s in the block it is declared in, from the declaration on. A type-only import (import type { USDT }) declares nothing.
  • A program that declares its own function named available keeps it. A registry name is tested by the name itself, never by a call.
The names are not a module. A program reads them with no import, and an import that reaches them is refused: import { USDT } from "@sauce/chain" fails with “@sauce/chain is the chain’s names, which a program reads with no import: write UniswapV3.Factory, USDC, Base.UniswapV3.Factory as they are.”

Chain names and chainId

  • A chain name (Base, Ethereum, Arc, Solana) qualifies another chain’s contract address, as above. Chains lists each name.
  • chainId is the id of the chain the program was compiled for. It is a constant folded into the bytecode.
chain-id.sauce.ts
ctx.chainId() reads the chain the program runs on. The two differ only when bytecode compiled for one chain is cooked on another.

Solana venue namespaces

Solana has no protocol namespaces. Each swap venue is a namespace of its own: its protocol, then its program. sauce globals --chain solana lists them under Venues.
orca.solana.sauce.ts
EVM and Solana covers the accounts a Solana program uses, and @sauce/router on Solana lists the venues.

Next steps

Contracts and ABIs

Call a namespace’s contracts, pass structs, destructure results.

@sauce libraries

@sauce/token and @sauce/router: every function and what it checks.

EVM and Solana

Which names, builtins and accounts each target has.

Tokens reference

Every token symbol per chain.