Skip to main content
SauceScript is one language for EVM and Solana. The compiler has two targets, and the chain you compile for picks one: A program that uses only what both targets have compiles for both. The calls into other contracts and the accounts a program touches are where the targets differ.
pay.sauce.ts
This file compiles for Base, Ethereum, Arc and Solana. On the EVM chains the compiled program is 328 bytes. On Solana it is 792 bytes, and the compiler also returns the four accounts the transfer moves through (below).

What compiles on each target

Something that exists on only one target is a compile error on the other, never a silent difference. The error names the target and, where there is one, the replacement: Builtins lists every builtin with its targets.

Accounts on Solana

A Solana transaction lists every account it reads or writes before it runs, each with a role: read-only, writable or signer. A program cannot reach an account the transaction did not list. When you compile for Solana, the compiler returns an account manifest: every account the program needs, in order, with its role and how to find it. sauce compile pay.sauce.ts --chain solana --json gives this manifest for the program above:
Each slot’s derive says where the account comes from: pot is the Pot, pot:USDC the Pot’s USDC account, @to:USDC the USDC account of main’s argument to, and the last slot is the SPL Token program. sauce run, sauce simulate and sauce tx derive every such slot from the owner, the Pot and main’s arguments, so the program runs with --args alone. An account given with --account for a slot derived from the Pot, the owner or the authority must be the one its derivation gives; another key is refused. A program that does not use the Pot derives the accounts its token methods move through from authority instead (authority, authority:USDC): the Pot through the Kitchen, the signer in a direct cook on the engine. Reading pot, pot.USDC or pot.balance binds the program to the Pot, so --via engine cannot run it. Reading only pot.owner does not bind a Pot and works with --via engine. A program gets its accounts three ways:

Derived accounts

derived.solana.sauce.ts
  • A program-derived address (PDA) is an address a program computes from seeds, with no key behind it. Only that program can sign for it. The Pot is one, derived by the Kitchen.
  • An associated token account (ATA) is the token account the token program derives for a holder and a mint. It is where that holder’s balance of that token lives.
  • Every holder and seed must be known when the cook is built: pot, pot.owner, ctx.self(), an Address parameter of main, a key written out, or a program account. For an account computed while the program runs, take an Account parameter instead.
  • .writable() and .signer() give an account the role a call needs, and chain: pot.owner.writable().signer(). Account expressions are read-only unless given a role; uses of the same account combine their roles.

Account parameters

accounts.solana.sauce.ts
  • Account is read-only; Account<Writable> and Account<Signer> declare the role the transaction must grant. The manifest records readonly, writable, readonly_signer or writable_signer per slot. Roles are what you declare; the compiler does not infer them from a call.
  • Account parameters are not encoded as arguments. sauce compile --json lists them as accountsToGive, and a cook missing one is refused before anything is read.
  • An Account exposes .address, .owner (the program that owns it) and .dataLen. Check .owner before trusting an account’s bytes: anyone can create an account with any layout. pot.owner is not this property: it is the Pot’s owner, described under ctx.self() and pot below.
  • An account passed as a parameter is used as attached. Nothing checks whose token account destination is; check it with ownerOf(mint, destination) when it matters, or take an Address and let the token method derive the account.

A program checks its accounts

A transaction built by someone else could attach a different account in a slot, so every derived account is checked on chain at the top of main, before the program’s own code. A wrong account fails the whole cook with a reason: wrong recipient account, wrong holder account, wrong program-derived address, wrong mint or wrong program. The Pot and the owner are pinned by their signatures.

Token programs

Every Solana token belongs to one of two token programs, and so does each of its token accounts: The token methods pick the write by the mint’s program, so USDC.transfer(…) and PYUSD.transfer(…) look the same in a program. Token(mint) reads the mint’s program from the cluster when the cook is built, and refuses a mint whose program cannot be read. Token-2022’s transfer fee is withheld from what arrives, and a mint whose transfer hook names a program is refused. @sauce/token lists both of its arms.

Protocol calls

On an EVM chain a program calls a contract through a protocol namespace or an ABI binding, typed by the ABI: UniswapV3.Factory.getPool(WETH, USDC, 500). See Contracts and ABIs. Solana has no protocol namespaces. A program calls another program with svm.call(program, [accounts], data), a cross-program invocation (CPI) with accounts the transaction already lists. A failed svm.call fails the whole transaction; Solana has neither .catch() nor try/catch. Swaps use the venue namespaces, which compile to @sauce/router’s Solana functions and derive the pool’s accounts:
venue.solana.sauce.ts

ctx.self(), pot and pot.owner

On Solana only a Kitchen cook makes the Pot sign, so a use of pot that signs as the Pot, such as a token write or pot.signer() in svm.call, fails with another account in its slot. pot.owner is the owner’s account, not the program that owns the Pot account. Write Account(pot.address).owner for that program. On Solana the engine’s program id owns every Pot account, and other accounts too, so acct.owner == ctx.self() does not prove an account is the Pot. To bind a slot to the Pot, re-derive the Pot’s address with crypto.findPDA from the Kitchen’s program id, "pot", the owner and the salt, and require the slot to be it. The Kitchen’s program id is on Deployments; see also Pot.

Transaction size and staging

A Solana transaction holds at most 1,232 bytes. A cook carries the program, its arguments (32 bytes for each Uint256 or Address), the accounts, the signatures and the transaction’s setup. sauce compile --json --chain solana reports the program’s size, and sauce tx reports whether the cook fits in one transaction. Compiled for Solana: A program too large for one transaction is staged: uploaded first, over several transactions, into a code buffer account of the Kitchen, then cooked from that buffer. The Kitchen runs only a buffer that was finalized, and finalizing checks the SHA-256 digest of the upload. After the cook the buffer is closed and its rent returned. sauce run stages a program when it does not fit, and sauce tx returns the steps as a plan for another signer. Every account a transaction lists takes 32 bytes. An address lookup table lets a transaction refer to an account with one byte. sauce run creates one when a cook’s accounts do not fit. On EVM chains the program travels as the calldata of the cook; there is no staging. A Solana cook is built for one release set’s engine and Kitchen: the engine’s program id and the Kitchen’s instruction format are part of it. Build every cook, staged or not, with the installed SDK, and never replay a serialized cook or staging instruction built by another SDK release.

Compute and memory on Solana

  • Compute budget: a transaction has 1,400,000 compute units, and every instruction spends from it, the engine’s and each called program’s. A cook that runs out fails whole. A concentrated-liquidity swap spends units for every initialized tick it crosses, so a wide price limit on a deep pool can exhaust the budget. sauce holds each Solana pool’s swap short of its limit where the budget would not cover it, and on Raydium’s code and Crema walks a swap on into the furthest tick array its accounts give (Swaps at a price and compute units).
  • Memory: a program has 64 KiB of working memory per run. Every cook requests a 256 KiB heap frame, larger than Solana’s default. sauce and the TypeScript client add that request. A cook built by hand prepends buildHeapFramePrepend() from @eco-incorp/sauce/svm once.

CPI depth

Solana allows 4 levels of CPI below a transaction’s instruction, and 8 on a cluster where SIMD-0268 is active. Through the Kitchen, the Kitchen’s call into the engine uses one of them: A direct cook (--via engine) runs with no Pot. The program acts with the signer’s authority and spends the signer’s own token accounts, so a program that names pot is refused there. A program that calls an aggregator, which calls a pool, which calls a token program, can need the extra level.

Native coins

On an EVM chain only evm.call(address, value, data) sends native value with a call; ABI bindings send none. On Solana a program moves lamports with a System Program transfer through svm.call. On Arc the native coin and the ERC-20 USDC are one balance at two scales: 18 decimals natively, 6 for the token. Sending native value and calling USDC.transfer move the same money, and the gas and the Kitchen’s fee are paid from it too. pot.USDC reads the 6-decimal token and pot.balance the 18-decimal native coin:
arc-pay.sauce.ts
Native fees paid by the program, such as bridge or messenger fees, spend the Pot’s balance, so leave enough USDC for them. The ordinary cook’s Kitchen fee comes from the owner’s attached transaction value and is forwarded before the program runs; a signed cook can pay it from existing Pot funds.

Next steps

  • Names: what each name resolves to on each chain.
  • @sauce libraries: @sauce/token and @sauce/router on both targets.
  • Kitchen and Pot: how a cook runs on each target.
  • Chains: every chain, its target and its support.