Skip to main content
The sauce command signs with a wallet it keeps on disk, a keystore, a raw key or a Solana keypair file. A policy file beside a wallet limits what that wallet sends through --wallet, per transaction and per 24 hours. Agents, wallets and keys covers what the files, the passwords and the policy protect against, and what they do not.

Prerequisites

  • The sauce command: npm install -g @eco-incorp/sauce (Installation).
  • A wallet, made with sauce wallet new or sauce wallet import.
  • To send from it, the release’s engine must be deployed on the chain: sauce pot --chain <chain> --owner <address> reports whether it is.

The wallet directory

Wallets live in ~/.sauce/wallets, or in the directory --wallets <dir> names. The command creates the directory with mode 0700. Each wallet is one file, <name>.json. It is written once, with mode 0600, by linking a temporary file into place, so an existing wallet is never overwritten. A name is up to 64 lowercase letters, digits, - and _, and starts with a letter or a digit. Beside a wallet, the command may find: These are hot wallets: whoever holds a wallet’s file and its password holds the key. Keep small balances in them.

The file format

An EVM wallet is a Web3 Secret Storage v3 keystore, the format geth and cast wallet write:
Because the format is standard, other tools open the same file:
sauce … --keystore wallets/agent.json reads it too, without the policy. A Solana wallet is the same envelope around the 64-byte keypair (seed, then public key, as solana-keygen writes it), with "kind": "solana" and the base58 public key as address. --keystore refuses a Solana wallet’s file for an EVM transaction. Each file holds its address in the clear. sauce wallet list, sauce wallet address, and sauce pot with --wallet or --keystore read the address without a password.

Password sources

A command reads a password from exactly one source: --password-file and --password-command drop one trailing newline. A wallet’s password is at least 8 characters. There is no --password <value> option: a value on the command line lands in shell history and the process list. Every key and password a command was given is filtered out of what it prints, error messages included. A signing command with the password from the environment:

Signers

A command that sends takes one signer: For sauce run and for a cook built with sauce tx, the signer is the Pot’s owner, because cook() accepts calls only from the owner. On Solana with --via engine, which uses no Pot, the signer is the authority the program acts with (Solana from the CLI). sauce pot --deploy is the exception on EVM chains: deploying a Pot is permissionless, so with --owner the signer sends and pays for another owner’s Pot, which belongs to that owner alone. On Solana the owner signs create_pot, so the signer is the owner. Which commands ask for the wallet’s password:
  • sauce pot without --deploy takes --wallet only to learn the owner’s address, and asks for no password. With --deploy it signs, so it needs the password.
  • sauce simulate takes --wallet the same way and asks for no password, except with --fund, where it signs the token pulls it simulates.
  • sauce run, sauce reclaim and the other commands that send ask for the password.
A key that must never reach this machine signs elsewhere: sauce tx prints the unsigned transaction and sauce send sends the signed one. Every send from a wallet is held to the supported chains: Ethereum, Base, Arc and Solana, and the testnets Arc Testnet, Base Sepolia, Ethereum Sepolia, Solana Devnet and Solana Testnet. A transaction on a preview chain is refused before it is measured or signed.

The spending policy

A policy is a file <name>.policy.json beside the wallet. Every field is optional:
agent.policy.json
The address in to is the wallet’s own Pot on Base, as sauce pot --chain base --wallet agent prints it: a cook is a transaction to the Pot.

Amounts

An amount is a string:
  • whole tokens: "100" is 100 USDC, "0.01" is 0.01 ETH;
  • base units, ending in n: "250000000000000000n" is 0.25 WETH.
A bare integer at or above 10^decimals of a registry token is read as whole tokens, with a note that says so:

What each limit counts

  • perTransaction caps what one transaction moves.
  • perDay caps what moved in the last 24 hours, every chain together, counted from the ledger.
  • to. Deploying the wallet’s own Pot through the released Kitchen counts as a transaction to that Pot. A policy that lets the wallet cook on its Pot therefore lets a command deploy that Pot first.
  • native counts the chain’s native coin leaving the wallet or the Pot the transaction cooks on. On Arc, the native coin and the USDC token are one balance: native USDC sent and a USDC allowance left count together against native and against a USDC limit.
  • native on Solana counts every account the wallet controls, net: the wallet and the Pot, their token accounts (wrapped SOL among them), durable nonce accounts whose authority is the wallet, and stake accounts whose withdrawer is the wallet. Wrapping SOL into the wallet’s own account and unwrapping it count only the fee. Rent paid into the wallet’s own token account, or into the Pot a cook creates, stays the wallet’s; rent paid into an account the wallet does not control (another key’s token account, a code buffer, a lookup table) counts, and so does the fee. A wallet the transaction gives to another program, or makes a durable nonce account another key commands, counts all it holds.
  • tokens are named by registry symbol (in any case) or by address: an ERC-20 or a Solana mint. They count what leaves the wallet or its Pot, and the allowances below. A token the policy does not name is not limited.

Allowances count as sent

An allowance a transaction leaves another key counts as sent, at the whole allowance, per transaction and in the day’s total, together with what the transaction moves of the token: the spender needs no later signature.
  • An approval above what a token’s limit leaves room for is refused, naming the limit.
  • A revoke, an allowance spent within the transaction (it counts as what it moved), and a spender that is the wallet or its own Pot count nothing.
  • Each allowance is its own permission, whoever gives it (the wallet or its Pot) and whichever contract it stands on (the token or Permit2). Changing one leaves the others as they are, and they add up.
  • Where a limited token’s decimals cannot be read, an approval of it is refused, as a transfer of it is.
The rule behind these: an allowance of a limited token to anyone but the wallet or its own Pot counts only where it is measured exactly, and is refused where it is not. On Arc the same holds for USDC under a native limit. On EVM chains, each allowance is read as the chain holds it once the transaction is done, one per contract, owner, token and spender:
  • An ERC-20’s own allowance, and one granted through Permit2 (its approve, or a permit the wallet signed), which lets the spender take the token wherever its owner approved Permit2 for it. Permit2’s lockdown clears one. A Permit2 grant counts at its amount, whatever its expiration, and only the registry’s Permit2 is read.
  • Each allowance is read in a simulation of its own that runs the transaction’s calls again on the block the transaction was simulated on, at the same time. The read counts only where that simulation is the transaction’s own execution again: the node reports the same block, and every call succeeds with the same logs, return data and gas used. An allowance that cannot be read so (the endpoint or the token does not answer, the endpoint cannot serve the block, or the calls do not run again as they ran) is refused where its token is limited.
  • An allowance is seen where the transaction reports it: a token’s Approval event, or Permit2’s own events. A token that sets an allowance with no event is not seen.
  • An approval of Permit2 for a limited token is refused, from the wallet or from its Pot in a cook. It makes spendable again every Permit2 allowance of that token given before, which the transaction does not report. Under such a limit, fund with --fund-mode permit, allowance or transfer (Funding), or approve Permit2 from a wallet without the limit.
On a local fork of Base at block 53011315 with the current contracts, a wallet named agent whose policy is { "tokens": { "USDC": { "perTransaction": "1", "perDay": "1" } } } ran a program that calls USDC.approve(Permit2.Permit2, <max>) and then Permit2.Permit2.approve(USDC, spender, 10000000, <expiration>), with 10 USDC in its Pot:
The command exited 1 and sent nothing: the wallet’s nonce, the Pot’s USDC and both allowances were unchanged, and the ledger gained no line. The wallet’s own Pot. A transaction counts a Pot as the wallet’s own only where it is: a Pot the plan deploys for the wallet, or one whose owner the chain shows to be the wallet. A cook’s calldata sent anywhere else, to any other address or to another owner’s Pot, is a payment like any other. A cook on a Pot whose owner the endpoint cannot read (the request fails, or the endpoint answers with an error of its own rather than the contract’s revert) is refused where a native or tokens limit applies. On Solana, an allowance is a delegate set or raised (SPL Token Approve or ApproveChecked) on a token account of the wallet or of a Pot the transaction cooks on:
  • An allowance raised and partly spent in the same transaction counts what it spent and what is left, also where it ends where it began. That holds whether the wallet’s own instruction raises it or a call a program makes, a Pot’s cook or a token Batch included. A token program’s call the endpoint reports so that an approval in it cannot be read is refused where a native or token limit applies.
  • Wrapped SOL is measured by its lamports, synced into its amount or not. Wrapped SOL an outside delegate may move counts against native too, and SOL the wallet sends into such an account is no longer its own once it is there.
  • What the wallet spends as a delegate from a token account it does not own counts as sent, against native too for wrapped SOL: from a Pot’s account in a transaction with no cook, and from any other key’s.
  • A token account of the wallet or the Pot handed to another owner is a send of all it holds, a transfer out of it in the same transaction included; so is an account closed and made again at its address for another token. A token account whose close authority goes to another key is refused where its token is limited, and so is a Token-2022 mint whose permanent delegate goes from the wallet to another key.
  • A balance counts in its mint’s decimals as they were before the transaction, also where the transaction makes the mint again with other decimals and leaves the raw amount as it was. Where a limited token’s decimals cannot be read, a transaction moving it is refused; other limits are not affected.
  • The measurement errs toward counting: a spend by the account’s owner beside an outside allowance that stands may count as if the allowance were raised, and a delegate’s spend from another key’s account counts though nothing of the wallet’s left. Wrapped SOL counts against native and against a wrapped-SOL token limit both.
A Token-2022 mint’s permanent delegate that the wallet never held moves tokens with no approval from the wallet: no policy limits it. ERC-721 and ERC-1155 transfers and approvals, setApprovalForAll included, are not counted either.

How a send is measured

What a transaction moves is measured, not read from its calldata:
  • An EVM transaction is made whole before the policy measures it: the gas it will be signed with (1.2 times its estimate), its fees, its nonce and its chain. It is simulated with eth_simulateV1 on the chain’s head, with native transfers traced, and every allowance read runs with the same gas and fees, since a program can read both. The wallet then signs exactly that transaction. The ERC-20 Transfer and Approval logs, Permit2’s grants and the native transfers out of the wallet and its Pot are what count.
  • A Solana transaction is simulated with the accounts it writes reported. The wallet’s and its Pots’ lamports and token accounts are compared before and after. A Pot is the account each Kitchen cook in the transaction names, read through the transaction’s lookup tables when it loads accounts from them, so a Pot at any bump the Kitchen accepts is measured. That includes a Kitchen cook that another program the transaction calls makes for the wallet.
  • A Solana transaction is held to the policy of every wallet the command has unlocked that it needs a signature from, whichever of them pays its fee. A planned transaction another tool built must be paid for by the wallet that sends it.
  • Every simulation of a transaction a wallet signs is unsigned, a swap’s stops in sauce run included: the wallet signs only a transaction its policy has let go, so an endpoint never holds one it could send itself.
What cannot be measured is refused wherever a limit needs it: an endpoint without eth_simulateV1, a cook whose native transfers are not traced, an allowance no simulation reads exactly, a cook on a Pot whose owner cannot be read, a Solana transaction whose lookup tables cannot be read, and a call to the Kitchen the endpoint reports without its data, its owner or its Pot account. A send that breaks a limit is refused before anything is signed. The refusal names the wallet, its policy file and the limit, in the form refused by the policy of wallet <name> (<policy file>): tokens.USDC.perTransaction is 100 USDC, and this transaction sends 250 USDC. A simulation shows what a transaction does on the state it ran on: the chain’s head, a moment before the transaction is sent. A program that reads the block it lands in (its time, its base fee, what another transaction changed first) may do something else there, and no simulation bounds that. A policy limits what the transaction does as simulated. A bound that must hold in whatever block the transaction lands in is the program’s own, such as a require on what it sends.

Signed cooks

sauce sign signs a cook that another account sends later, on what the chain holds then, so a simulation when you sign cannot measure what it will move. A wallet whose policy sets native or any tokens limit refuses to sign one, naming the limit:
chains and to still apply: the cook’s chain, and its Pot as the address. A policy with "chains": ["ethereum"] refuses a cook on Base (chains allows ethereum, and this cook is on base), and one with "chains": ["base"] alone signs it. Sign from a wallet without native or tokens limits, or with the key kept elsewhere.

Funding a run under a policy

Before --fund signs a pull or prepares a funding transaction, the wallet’s policy checks the chain and the Pot against chains and to. A transaction the run sends before the cook is then checked against to with its actual destination, the token contract: the approve of --fund-mode allowance, the transfer of --fund-mode transfer, and the first approval of Permit2 under --fund-mode permit2. A policy whose to lists only the Pot refuses all three. On a local fork of Base at block 53116964 with the current contracts, with "to": ["<the wallet's Pot>"], sauce run keep.sauce.ts --chain base --wallet docs --fund USDC:1 --fund-mode transfer --yes printed:
That address is Base’s USDC. Adding it to to let the allowance, transfer and permit2 runs through on the same fork. --fund-mode permit, and permit2 once Permit2 is approved, send nothing before the cook and pass with the Pot alone. --fund signs and sends only for the wallet’s own Pot: one the chain shows is the wallet’s, or the one the run deploys first (Funding). What the funding lets the Pot take is spent only by a cook on that Pot, and that cook is measured and held to the native and tokens limits when it is sent. Under a token limit, the approval of Permit2 that the default --fund-mode permit2 sends first is refused. On the fork at block 53011315 above, sauce run keep.sauce.ts --chain base --wallet agent --fund USDC:0.5 --yes printed:
Nothing was sent. The same run with --fund-mode permit pulled 0.5 USDC into the Pot and succeeded.

How the file is checked

Every command that signs with --wallet reads the policy first, before it asks for the password. Signing the same file with --keystore, --private-key, cast wallet or geth applies no policy, and anyone who has the file and its password can loosen or ignore it (Agents, wallets and keys).
  • An unknown field is an error, so a typo never lifts a limit:
  • A file that others can write is refused, because they could loosen it. Run chmod 600 on it. On Windows the command does not check file permissions.
  • sauce wallet list marks each wallet that has a policy with (policy), and prints a warning for a policy that would be refused:
With no policy file, sends work, and each signing command notes that no limits are set:

The ledger

<name>.ledger.jsonl records what the wallet spent, one JSON object per line, appended with mode 0600. Each send writes two lines, linked by id. Their shape:
  • reserved is written when the transaction passes the policy, before it is sent. spend holds, per limit, what the transaction counts against it, in base units.
  • sent follows once the transaction is sent, with its hash.
  • released follows instead, with why, when the transaction is not sent. A released reservation no longer counts.
The check and the reservation happen under the wallet’s lock file, so two processes cannot both spend the last of a day’s limit. perDay adds every reservation from the last 24 hours that was not released. Without a policy, nothing is written to the ledger. A ledger line that is not JSON, such as one cut short by a crash, makes every limited send refuse until you remove that line, because the day’s spending can no longer be counted. A missing ledger counts nothing spent, so deleting it restarts every perDay count.

Next steps