What a Swaputer World is
A Uniswap v4 pool whose hook turns a buy into a program call. The swap carries the call, a real bytecode interpreter runs it inside afterSwap, part of the tokens the buyer just received is burned as fuel, and the swap, the program, the state change and the burn all live or die together.
There is one way in. A buy on one pool, with a program call in its hookData. The machine refuses every other caller, and that refusal is a line of code rather than a promise: execute reverts for anybody who is not the hook. So the sentence "a program runs only when somebody enters the World through the pool" is a property of the deployment, not a description of intent.
the five files //what each one is for
| file | role | what it decides |
|---|---|---|
| Swaputer.sol | the medium | An ERC20 and nothing else. 1 000 000 000 whole tokens, 18 decimals, fixed at deployment. No mint, no owner, no tax, no pause. |
| Programs.sol | the card store | Holds every program as the runtime of its own contract, with the jump destination bitmap packed in front of it. Installing is permissionless and a program can never be edited. |
| SVM.sol | the machine | The interpreter. 64 stack slots, 64 words of memory, persistent per program storage, and no opcode anywhere that takes an address. |
| SwaputerHook.sol | the door | Reads the hookData, runs the machine, prices the fuel against the rate the swap itself achieved, takes it out of the proceeds and burns it. |
| HookDeployer.sol | the press | Puts the hook on the one address whose low bits spell its permissions, or reverts and leaves nothing standing. |
the sequence //one call stack, top to bottom
| step | who | what happens |
|---|---|---|
| 1 | the buyer | Sends an exact input buy on the pool with hookData beginning 0x53575054. |
| 2 | PoolManager | Executes the swap. The buyer owes ether and is owed tokens. |
| 3 | the hook | afterSwap. Reads the delta, so it has the exact wei in and the exact tokens out. |
| 4 | the machine | Runs the named program with the fuel ceiling the buyer set. Storage is written here. |
| 5 | the hook | Prices the fuel actually used, checks both caps, takes it out of the tokens and burns it. |
| 6 | PoolManager | Settles. The buyer receives the tokens less the fuel. |
| any | a failure | Every step above is undone. There is no partial result. |
the pool //pinned at initialize, once, for ever
- pair
- ether against SWAPUTER. Ether is currency0 because address zero sorts first, so zeroForOne is a BUY everywhere in the hook.
- fee
- 10 000, which is 1.00 per cent, static. It is Uniswap's and it goes to whoever provided the liquidity.
- tickSpacing
- 200. The Uniswap app defaults this to 60 and will refuse the pool until the parameter is corrected by hand.
- hook fee
- none. Not a reduced one, not a decaying one, not one that goes to a treasury. The only thing the hook takes is fuel, only on a swap that asked for compute, and it burns every unit of it.
- second pool
- impossible. beforeInitialize records the first pool id and reverts for every later one.
The door
The hook is the only entrance. It decides what is a program call and what is an ordinary buy, and it treats the two kinds of wrong hookData completely differently on purpose.
the call //what rides in hookData
| field | type | what it does |
|---|---|---|
| MAGIC | bytes4 | 0x53575054, which spells SWPT. Four bytes, then a plain abi.encode of the rest. |
| program | uint32 | The program id to run. Ids run from 1. Zero is never a program. |
| fuelLimit | uint256 | A ceiling, exactly as a gas limit is. At most 2 560. You are charged for fuel USED, and the unspent remainder is not burned. |
| maxBurn | uint256 | Your own slippage limit on fuel, in token units. Exceeded, the whole transaction reverts. |
| account | address | The address the program will see through the ACCOUNT opcode. Not authenticated. Read the limits panel before you rely on it. |
| args | uint256[] | Up to 8 words, read by the ARG opcode. More than that reverts. |
- the magic
- 4 bytes.
- the head
- 192 bytes, which is five slots and then the argument array's own length word. It is not 160: the fifth head slot holds the array's OFFSET, and the array follows it.
- each argument
- 32 bytes on top of that, up to 8 of them.
- shorter than that
- reverts with BadHookData rather than dying inside the decoder with nothing to read. The gate was 164 in a first draft, which let a 164 byte payload through to fail with empty revert data: exactly the undiagnosable failure the ignore rule above exists to prevent.
the two ways hookData can be wrong //and why they are not treated alike
- hookData that is not ours is IGNORED, not rejected. Routers and aggregators put their own bytes in hookData. Anything that does not open with 0x53575054 is treated exactly as an empty hookData: no program, no fuel, no burn, no delta. If the hook reverted on everything it could not parse, an ordinary buy through such a router would fail for a reason nobody could diagnose.
- Our hookData that is broken REVERTS LOUDLY. Past the magic the caller meant it, so everything is strict. A malformed call does not get handed a successful buy and a World that never heard from it.
what cannot happen here //four branches that do not exist
| claim | how it is enforced |
|---|---|
| A sell never runs a program. | afterSwap returns zero immediately unless zeroForOne is true. hookData on a sell is ignored rather than rejected, deliberately: rejecting it would let a router that attaches its own bytes block an exit. |
| A plain buy never runs a program. | Empty hookData, or hookData without the magic, returns zero before anything is decoded. A buyer who wants the token and nothing else pays the pool fee and no more. |
| Nobody can be charged more than they agreed. | Two caps, both always applied: the buyer's own maxBurn, and a hard ceiling of 50 per cent of the tokens the swap delivered. |
| The hook never holds anything. | Fuel is taken and burned in the same call. There is no withdraw, no sweep, no owner and no address in the file that receives value. |
exact input only //a v4 mechanic, not a preference
A program call requires an exact INPUT buy: you say how much ether you are spending, not how many tokens you want. A return delta can only reach the UNSPECIFIED currency. On an exact input buy the specified currency is ether, so the unspecified one is the token and the fuel can be taken in the token the buyer just received. On an exact OUTPUT buy the specified currency is the token, the unspecified one is ether, and there is no way to take fuel out of the proceeds at all. So an exact output buy carrying the magic reverts rather than silently running for free. An exact output buy WITHOUT the magic is an ordinary swap and is untouched.
the opening price is pinned, and the opening caller is not //why that is the right way round
A hook has to exist before a pool can name it, so its address is public for at least one block before the launcher gets to use it. In that window anybody can call initialize with the identical key. Every shape check passes, the pool id is recorded, and the one pool rule then locks the launcher out FOR EVER at whatever price the front runner chose. Because the machine can only ever be wired to one door, recovery would mean a new hook, a new machine and a new everything.
The obvious guard is to require that the caller be the launcher. It does not work: the Uniswap app creates pools through the PositionManager, so the caller is a router for anybody not sending raw calldata, and admitting that router admits everybody.
- what it is
- the one tick this pool may ever open at, fixed at construction with no setter.
- where it lives
- in the hook's constructor arguments, which means it is fixed before the salt is mined, which means the hook's own address commits to it.
- the band
- one tick wide rather than one number, because the app computes its own square root price and rounding must not become a revert nobody can diagnose. Below the tick, or at the next tick up, the initialize reverts with WrongOpeningPrice.
- what a front runner gets
- the pool the launcher already chose, at the price the launcher already chose, with no liquidity in it and a token that cannot be transferred until trading opens. Which is to say, nothing.
what it refuses, by name //every error the door can raise
| error | when |
|---|---|
| PoolTaken | a second pool, ever. |
| WrongPair | currency0 is not ether, or currency1 is not the token. |
| WrongFee | the fee is not 10 000. |
| WrongTickSpacing | the tick spacing is not 200. This is the one the Uniswap app trips over: it defaults to 60. |
| WrongHook | the key names a different hook. |
| BadOpeningTick | the hook was constructed with an opening tick outside what the tick maths accepts. It is a constructor check, so it can only ever fire once and it fires before anything exists. |
| WrongOpeningPrice | the pool is being opened at a price outside the one tick band above. |
| ExactInputOnly | a program call on an exact output buy. |
| BadHookData | the payload carries the magic but is too short to be a call, or the delta is not the shape a buy makes. |
| NothingBought | the buy delivered no tokens at all: too small to round to anything, or stopped dead by a price limit. It is a separate error from BadHookData on purpose, because it is the one a person is most likely to have to read. |
| FuelTooExpensive | the burn is over the buyer's own maxBurn. |
| OverShareCap | the burn is over 50 per cent of the tokens delivered. |
the address is the permission //the hook flag word
| bit | flag | callback | on |
|---|---|---|---|
| 13 | 0x2000 | beforeInitialize | yes |
| 12 | 0x1000 | afterInitialize | |
| 11 | 0x0800 | beforeAddLiquidity | |
| 10 | 0x0400 | afterAddLiquidity | |
| 9 | 0x0200 | beforeRemoveLiquidity | |
| 8 | 0x0100 | afterRemoveLiquidity | |
| 7 | 0x0080 | beforeSwap | |
| 6 | 0x0040 | afterSwap | yes |
| 5 | 0x0020 | beforeDonate | |
| 4 | 0x0010 | afterDonate | |
| 3 | 0x0008 | beforeSwapReturnDelta | |
| 2 | 0x0004 | afterSwapReturnDelta | yes |
| 1 | 0x0002 | afterAddLiquidityReturnDelta | |
| 0 | 0x0001 | afterRemoveLiquidityReturnDelta |
- value
- 0x2044, which is 8 260 in decimal. The low fourteen bits of the hook's address must equal this exactly.
- liquidity bits
- all six clear. The hook is never consulted when anybody adds or removes liquidity, so it cannot interfere with a liquidity provider getting out.
- a miss
- is silent. A hook mounted one bit wrong deploys, the pool initialises, swaps succeed, and it is simply never called again. That is why the deployer is told the address AND the flag word and refuses to leave anything else standing.
The shape of it
A stack machine with word addressed memory, persistent per program storage, no access to any address, and a fuel budget bought with burned tokens.
memory is words, which is not what the EVM does //
Memory here is 64 slots of one word each, indexed 0 to 63, not a byte array. MLOAD 3 reads the whole of word three. This is a deliberate divergence and it is the single largest reason the interpreter is affordable: byte addressing forces every access through offset arithmetic, bounds maths and expansion pricing, while word addressing makes MLOAD and MSTORE into an array index.
Everything that would be a buffer in the EVM is therefore a run of words here. RETURN, LOG data and the body of a SPAWN are all measured in words, never in bytes.
the registers //sizes, and what happens at the edge
| thing | size | what happens past it |
|---|---|---|
| stack | 64 words | StackOverflow. Reading an empty stack is StackUnderflow. Both revert the swap. |
| memory | 64 words | MemoryRange. There is no expansion and no expansion price: the whole of it is there from the first instruction. |
| arguments | 8 words | TooManyArgs. ARG past the end reads zero instead, which is the one boundary here that is soft. |
| call depth | 4 | TooDeep. The entry program is depth zero, so there are 5 levels in all. |
| frames in one swap | 16 | TooManyFrames. Siblings count, not just nesting. |
| program length | 2 048 bytes | TooLong at install. A program written by SPAWN is capped much lower, at 16 words which is 512 bytes. |
storage //one program, one bank
Storage is keyed by program and then by word. Only a frame running program N can ever write store[N], and there is no opcode that reads another program's storage at all. A program that wants somebody else's number has to CALL them and be given it. Two view functions on the machine read a World from outside, one for a scattered set of keys and one for a contiguous run of them.
frames //what a CALL is and is not
- what it takes
- a program ID, not an address. It lands inside this same interpreter, in a new frame, with its own stack and its own memory.
- arguments
- a run of the caller's memory words, at most 8.
- results
- written straight into the caller's memory, truncated to the retMax the caller declared. There is no RETURNDATACOPY because there is nothing to copy from.
- fuel
- comes out of the SAME tank. A program that calls another program is spending its own budget on it, exactly as gas works.
- failure
- is not catchable. A callee that reverts reverts the caller, the entry program, the fuel and the swap. There is no try, no status flag and no partial result.
programs can reenter each other //and the machine will not stop them
There is no external reentrancy surface. The only two contracts the machine can reach are the program store, which has no way back in, and the token, whose total supply is a view. That is a true statement about CONTRACTS and it would be a badly misleading one about PROGRAMS, so here is the exact shape of what it does not cover.
Any program may CALL any other program, with arguments of its choosing, at any depth below 4. There is no allowlist, no guard opcode and no lock. So this happens, and it is the author's bug rather than the machine's:
| step | what happens |
|---|---|
| 1 | A writes store[A][0] equal to 1 |
| 2 | A calls B |
| 3 | B calls A. A second, live frame of A. |
| 4 | the inner A writes store[A][0] equal to 99 |
| 5 | the frames unwind |
| 6 | the outer A reads key 0 and gets 99, not the 1 it just wrote |
Everything else holds. A reentrant program can corrupt its OWN storage and nothing else: not your balance, not another program's storage, not the pool.
the jump destination bitmap //why a jump costs one bit test
A jump must land on a real JUMPDEST, and real means the 0x5b it lands on is an instruction and not a byte sitting inside a PUSH immediate. Deciding that requires walking the program from the start, because PUSH is the only thing that makes the byte stream ambiguous. Walking 2 048 bytes inside a swap is not acceptable, so the walk happens exactly once, at install, paid for by the author. The result rides along inside the stored blob, so the machine copies code and bitmap together in one EXTCODECOPY at the start of a frame and every later jump is a memory read and a bit test.
- byte 0
- 0x00, a STOP. The SSTORE2 convention: it makes the blob refuse to do anything if somebody calls it as a contract.
- bytes 1 onward
- the bitmap, one bit per program byte, ceil(N / 8) bytes of it.
- then
- the program, N bytes.
- editing it
- is not possible. The code is the runtime of its own contract and there is no SSTORE in the store that touches a blob. Nobody can upgrade a Swaputer program, including its author. A new version is a new id.
The workbench on this site builds that blob in the browser. Assemble anything and you can read the bitmap off the page byte by byte.
Instruction set
All of it. Where an opcode has an EVM counterpart it carries the EVM's number, because the same number meaning the same thing is worth more than tidiness. Everything specific to a Swaputer World is in the 0xb0 block and has no EVM counterpart at all. Anything not on this page is invalid and reverts: there is no undefined but tolerated region.
SUB a b means a was pushed last and is on top, and the result is a minus b. That is the EVM's order and this machine keeps it.| op | name | takes | leaves | fuel | notes |
|---|---|---|---|---|---|
| halting | |||||
| 0x00 | STOP | 5 | End the frame and return nothing. Falling off the end of the code does the same. | ||
| 0xf3 | RETURN | off words | 5 | End the frame returning a run of memory WORDS. off plus words may not pass 64. | |
| 0xfd | REVERT | code | 5 | Abandon everything with one word of reason. The swap that carried this call dies with it. | |
| arithmetic, all wrapping, exactly as the EVM wraps | |||||
| 0x01 | ADD | a b | a plus b | 5 | |
| 0x02 | MUL | a b | a times b | 5 | |
| 0x03 | SUB | a b | a minus b | 5 | |
| 0x04 | DIV | a b | a over b | 5 | Division by zero is zero. There is no SDIV. |
| 0x06 | MOD | a b | a mod b | 5 | Modulus by zero is zero. There is no SMOD. |
| 0x08 | ADDMOD | a b m | a plus b mod m | 5 | The sum is not truncated before the modulus. m of zero gives zero. |
| 0x09 | MULMOD | a b m | a times b mod m | 5 | The product is not truncated before the modulus. m of zero gives zero. |
| 0x0a | EXP | a b | a to the b | 21 plus 64 a byte of b | THE ONLY ARITHMETIC PRICED ON ITS OPERAND. Square and multiply, one round per bit of the exponent, so the price is per significant BYTE of the exponent: 21 for an exponent of zero, 2069 for a full width one. Two full width exponentiations do not fit in a tank. A flat price here was a hole straight through the instruction bound, and section 3 of the machine says what it cost. |
| comparison and bits | |||||
| 0x10 | LT | a b | a below b | 5 | Unsigned. |
| 0x11 | GT | a b | a above b | 5 | Unsigned. |
| 0x12 | SLT | a b | a below b | 5 | Two complement signed. Use this one on TICK. |
| 0x13 | SGT | a b | a above b | 5 | Two complement signed. |
| 0x14 | EQ | a b | a equals b | 5 | |
| 0x15 | ISZERO | a | a is zero | 5 | |
| 0x16 | AND | a b | a and b | 5 | |
| 0x17 | OR | a b | a or b | 5 | |
| 0x18 | XOR | a b | a xor b | 5 | |
| 0x19 | NOT | a | not a | 5 | |
| 0x1b | SHL | a b | b shifted left a | 5 | A shift of 256 or more gives zero. |
| 0x1c | SHR | a b | b shifted right a | 5 | A shift of 256 or more gives zero. There is no SAR. |
| hashing | |||||
| 0x20 | HASH | a b | digest | 17 | keccak256 of the two words packed in that order, a first. Not a memory range: there is no length to get wrong. |
| where you are | |||||
| 0x30 | ADDRESS | id | 5 | This program's own id. Not an account. | |
| 0x32 | ORIGIN | id | 5 | The program the swap named. Equal to ADDRESS in the entry frame. | |
| 0x33 | CALLER | uint160 | 5 | Whoever called the PoolManager. AUTHENTICATED, and it is the ROUTER if a router was used. | |
| 0x34 | VALUE | wei | 5 | The wei this swap put into the pool, pool fee included. | |
| 0x42 | TIMESTAMP | seconds | 5 | ||
| 0x43 | NUMBER | block | 5 | ||
| 0x58 | PC | pc | 5 | The offset of this instruction, before it advances. | |
| 0x59 | MSIZE | words | 5 | The memory high water mark in WORDS, not bytes, and it counts words WRITTEN. MLOAD does not raise it. That differs from the EVM, where touching memory expands it: here all 64 words exist from the first instruction and an unwritten one reads zero, so a read has nothing to expand. | |
| 0x5a | FUEL | left | 5 | What is left AFTER this instruction has been paid for. | |
| the world, and nothing else | |||||
| 0xb0 | BOUGHT | units | 5 | Tokens the swap delivered, BEFORE the fuel is taken. The buyer keeps less than this. | |
| 0xb1 | TICK | tick | 5 | The pool tick at the end of the swap, two complement. A negative tick reads as a very large unsigned number, so compare it with SLT. | |
| 0xb2 | SUPPLY | units | 5 | The token total supply right now. One of the machine's two external calls. | |
| 0xb3 | FROM | id | 5 | The program that called this one. Zero in the entry frame. | |
| 0xb4 | DEPTH | n | 5 | Zero in the entry frame, at most 4. | |
| 0xb5 | ARG | i | arg i | 5 | Replaces the index on the stack. An index past the end reads zero rather than reverting. |
| 0xb6 | NARGS | n | 5 | How many arguments this frame was given, at most 8. | |
| 0xb7 | ACCOUNT | uint160 | 5 | The address the hookData CLAIMED. Authenticated by nothing. Address a result at it, never take anything from it. | |
| the stack | |||||
| 0x50 | POP | a | 5 | ||
| 0x60..0x7f | PUSH1 to PUSH32 | v | b plus n | n immediate bytes, big endian. An immediate running off the end of the code zero extends, exactly as the EVM does it. PUSH1 costs 5 and PUSH32 costs 36. | |
| 0x80..0x87 | DUP1 to DUP8 | copy | 5 | There is no DUP9 and above. | |
| 0x90..0x97 | SWAP1 to SWAP8 | 5 | There is no SWAP9 and above. | ||
| memory, which is 64 WORDS and not a byte array | |||||
| 0x51 | MLOAD | i | word | 7 | Word i, whole. An index of 64 or more reverts. |
| 0x52 | MSTORE | i v | 7 | Word i, whole. An index of 64 or more reverts. | |
| storage, which is this program's and no other program's | |||||
| 0x54 | SLOAD | k | v | 29 | Reads store[this program][k]. There is no opcode that reads another program's storage. |
| 0x55 | SSTORE | k v | 261 | Writes store[this program][k]. At 261 a store, a full tank buys 9 of them. | |
| control | |||||
| 0x56 | JUMP | dest | 5 | dest must be a JUMPDEST byte that is an instruction, tested against the bitmap Programs.sol built at install. | |
| 0x57 | JUMPI | dest cond | 5 | Jumps when cond is not zero. | |
| 0x5b | JUMPDEST | 5 | A landable byte. Costs fuel like anything else. | ||
| logging, measured in words | |||||
| 0xa0 | LOG0 | off words | 37+16 a word | Emits Log0(program, data). | |
| 0xa1 | LOG1 | off words t0 | 53+16 a word | One indexed topic. There is no LOG3. | |
| 0xa2 | LOG2 | off words t0 t1 | 69+16 a word | Two indexed topics. There is no LOG4. | |
| calling, which never names an address | |||||
| 0xf1 | CALL | id argOff argWords retOff retMax | words | 69 | Calls a PROGRAM ID inside this same interpreter. Results land straight in your memory, truncated to retMax. At most 8 argument words, at most depth 4, at most 16 frames in one swap, and the fuel comes out of the SAME tank. |
| 0xf0 | SPAWN | off words tag | id | 1029+32 a word | Installs a new program written out of memory, at most 16 words which is 512 bytes. A full size spawn costs 1541, so one tank buys exactly one. Refused under preview. |
what is deliberately not here //and cannot be added later
No SDIV, no SMOD, no SAR, no BYTE, no CALLDATA anything, no RETURNDATACOPY, no DUP9 to DUP16, no SWAP9 to SWAP16, no LOG3 or LOG4, no PUSH0. Each was left out because it costs contract size and nothing in the first set of programs needed it. The machine has no upgrade path, so the table above is the instruction set for ever.
The fuel model
Computation is measured by the bytes actually executed, priced in ether, paid in token and burned. This panel is the whole economics of the machine and it is four lines of arithmetic.
the formula //
- a PUSH of n bytes
- BASE (4) plus n. PUSH1 costs 5 and PUSH32 costs 36.
- everything else
- BASE (4) plus 1 plus the surcharge in the table below. The plus one is the dispatch every instruction pays whether it carries bytes or not.
- the cheapest instruction
- costs 5. Nothing in the set costs less, PUSH1 included.
- a frame
- costs nothing by itself. Opening one is priced by the CALL that opened it.
the surcharges //everything that is not 5
| instruction | surcharge | total | what a full tank buys |
|---|---|---|---|
| EXP | 16 plus 64 a byte of exponent | 21 to 2 069 | one full width exponentiation, and not two |
| SSTORE | 256 | 261 | 9 stores in one swap |
| SPAWN | 1 024 plus 32 a word | 1 541 at 16 words | exactly one full size spawn |
| CALL | 64 | 69 | 37 calls, but only 15 can land: frames run out first |
| LOG0 | 32 plus 16 a word | 37 empty | 69 empty logs |
| LOG1 | 48 plus 16 a word | 53 empty | |
| LOG2 | 64 plus 16 a word | 69 empty | |
| SLOAD | 24 | 29 | 88 reads |
| HASH | 12 | 17 | |
| MLOAD, MSTORE | 2 | 7 |
why EXP is priced on its exponent //and what a flat price cost
Every other instruction in the set does a fixed amount of work. EXP does not: it is square and multiply, and its loop runs once per BIT of the exponent. Priced flat, its fuel would be constant while its gas was not, and that is a hole straight through the whole of the section above. The first draft priced it flat. A tank spent on full width exponents cost about 2.8 million gas where a tank spent on one byte instructions cost about six hundred thousand, so the ceiling the fuel model claimed to set was off by roughly five times. The EVM prices its own EXP per byte of exponent for exactly this reason and so does this machine.
- an exponent of zero
- 21 fuel. No rounds, no surcharge past the base.
- each significant byte
- 64 fuel on top.
- a full width exponent
- 2 069 fuel, which is most of a tank. Two of them do not fit in one, and that is the correct statement about a 256 round loop.
- how it is counted
- significant bytes of the exponent, shifted down eight bits at a time. At most 32 turns, and the count depends on the exponent rather than on the fuel, so it cannot be spun.
- when you pay
- after the stack is checked and before the exponent is popped. EXP is the one instruction in the set whose price cannot be known until an operand has been read.
the bound //arithmetic, not a counter somebody remembered to add
Running out of fuel does not halt quietly and does not return a flag. It reverts the whole transaction, which means it reverts the buy. Fuel is shared across every frame, exactly as gas is.
what the programs actually cost //measured, not estimated
| program | bytes | fuel | of tank | instructions | what it does |
|---|---|---|---|---|---|
| GRIND | 512 | 2 560 | 100.0% | 512 | the worst case in gas, and it is the dullest program in the set |
| TALLY | 24 | 647 | 25.3% | 17 | counts entries and records who each one was |
| ROLL | 18 | 79 | 3.1% | 13 | a die, and a warning about what a die is not |
| CLAIM | 37 | 394 | 15.4% | 22 | first claim on a key keeps it, and a second one reverts the buy |
| LEDGER | 36 | 991 | 38.7% | 25 | running totals of wei in, tokens out and entries |
| FIB | 60 | 1 539 | 60.1% | 270 | a real loop, so the fuel meter is legible |
| DOUBLE | 14 | 47 | 1.8% | 9 | a component, small enough to be called rather than read |
| RELAY | 24 | 188 | 7.3% | 24 | composition: one program calling another by id |
| DEEP | 12 | TooDeep | nil | nil | calls itself for ever, and is stopped by the depth cap |
| SEED | 51 | 1 151 | 45.0% | 12 | writes a program, which is the SPAWN opcode doing its whole job |
FIB is shown at n equals 10, CLAIM at key 42 and RELAY calling DOUBLE, all on a fresh World. FIB is the only one whose cost depends on its argument: each extra loop turn costs 143 fuel, so the tank runs out at n equals 17. GRIND is exactly a tank, by construction.
what fuel costs in ether //and why no price appears on this page
- one unit
- 400 000 000 000 wei, which is 0.0000004 ether.
- a full tank
- 1 024 000 000 000 000 wei, which is 0.001024 ether. That is the most any single swap can ever be charged, whatever it does.
- paid in
- token. The conversion uses the rate this very swap achieved, which the callback already has sitting in its BalanceDelta. No oracle and nobody to trust.
- the formula
- burn equals fuelUsed times 400 000 000 000 times tokensOut, divided by weiIn. The multiply is done before the divide and the intermediate is carried at full width.
- why in ether
- a price fixed in tokens would make compute cost more in real terms every time the token appreciated, and a World whose running costs rise with its own success is a World that stops being used.
the worked example //share of the tokens delivered, by size of buy
| program | fuel | fuel in ether | 0.002 ETH | 0.01 ETH | 0.05 ETH | 0.25 ETH | 1.0 ETH |
|---|---|---|---|---|---|---|---|
| GRIND | 2 560 | 0.001024 | 51.2000% reverts | 10.2400% | 2.0480% | 0.4096% | 0.1024% |
| TALLY | 647 | 0.0002588 | 12.9400% | 2.5880% | 0.5176% | 0.1035% | 0.0258% |
| ROLL | 79 | 0.0000316 | 1.5800% | 0.3160% | 0.0632% | 0.0126% | 0.0031% |
| CLAIM | 394 | 0.0001576 | 7.8800% | 1.5760% | 0.3152% | 0.0630% | 0.0157% |
| LEDGER | 991 | 0.0003964 | 19.8200% | 3.9640% | 0.7928% | 0.1585% | 0.0396% |
| FIB | 1 539 | 0.0006156 | 30.7800% | 6.1560% | 1.2312% | 0.2462% | 0.0615% |
| DOUBLE | 47 | 0.0000188 | 0.9400% | 0.1880% | 0.0376% | 0.0075% | 0.0018% |
| RELAY | 188 | 0.0000752 | 3.7600% | 0.7520% | 0.1504% | 0.0300% | 0.0075% |
| SEED | 1 151 | 0.0004604 | 23.0200% | 4.6040% | 0.9208% | 0.1841% | 0.0460% |
| a full tank | 2 560 | 0.001024 | 51.2000% reverts | 10.2400% | 2.0480% | 0.4096% | 0.1024% |
Each cell is the percentage of the tokens the swap delivered that gets burned as fuel. A cell marked reverts is over the 50 per cent hard cap, and the cap is not a clamp: the hook reverts and the buy does not land.
the two caps //both of which always apply
| cap | set by | what it does |
|---|---|---|
| maxBurn | the buyer, in the hookData | A slippage limit on fuel in token units. Over it, the transaction reverts with FuelTooExpensive. Tested against the LIMIT before the program runs and against the USE after it. |
| 5 000 basis points | the contract, at compile time | Fuel can never take more than 50 per cent of the tokens the swap delivered, whatever the rate does, whatever the buyer passed and whatever the program did. Over it, the transaction reverts with OverShareCap. Tested the same two times. |
Neither cap is a clamp. Over either one the hook REVERTS and the buy does not land. That is the right behaviour for a machine whose whole promise is that the swap and the program live or die together, and it is worth saying out loud because "cap" usually means the charge gets trimmed.
gas, which is not fuel //measured end to end, never estimated
Fuel bounds what the machine will do. The transaction still needs real gas on top, and the two are not the same size. Every figure below is a whole transaction against the real PoolManager on a mainnet fork, taken at block 25 980 134. They are the only numbers on this site that are not derivable from the source, and they move with the compiler profile and with any edit to the contracts, a comment edit included.
| transaction | gas |
|---|---|
| plain buy, no hookData | 96 002 |
| buy + ROLL (79 fuel) | 160 277 |
| buy + TALLY (647 fuel, 2 stores) | 191 217 |
| buy + LEDGER (991 fuel, 3 stores) | 240 290 |
| buy + FIB(17) (2540 fuel) | 635 483 |
| buy + GRIND (2560 fuel, 512 instructions) | 702 240 |
| buy + SEED (1151 fuel, writes a program) | 306 625 |
| sell | 99 698 |
| install a 60-byte program | 178 009 |
| case | fuel | gas |
|---|---|---|
| one STOP, the floor | 5 | 42 717 |
| 512 instructions | 2 560 | 600 526 |
| 9 stores | 2 349 | 250 254 |
| a full size SPAWN | 1 541 | 469 135 |
| 5 frames, the deepest legal chain | 276 | 121 270 |
| an install at 2 048 bytes, paid by the author | nil | 1 663 703 |
Taken by the repository's own assertion script against a machine it deployed for the purpose, so the two tables are independent of each other. Net of the call floor, a fuel unit costs about 218.32 gas.
the caps are checked twice //once before the machine runs, once after
Both caps are tested against fuelLimit before the program starts, and again against fuelUsed once it has finished. The first test is what stops a buy too small to afford its own fuel from executing an entire program and only then reverting: a doomed call now costs about a tenth of what a full tank costs.
manipulating the rate //the direction, stated plainly
The rate comes from one swap's own execution, so a large buy pays at its own average price, slippage and pool fee included. Somebody could move the pool first to change it. It does not pay, and the direction is worth being explicit about: to make fuel cheaper IN TOKENS you must make the token MORE expensive, which means buying it, which costs the pool fee on the way in and the pool fee again on the way out. The most that can be saved is 0.001024 ether, and both caps still bind regardless. A round trip large enough to shift the rate meaningfully costs multiples of that.
Limits and refusals
What a program cannot do, why the list is short on purpose, and every place where the honest answer is less comfortable than the marketing one.
what a program cannot do //
There is no opcode in this machine that takes an address. No call to a contract, no delegatecall, no staticcall, no selfdestruct, no transfer, no balance, no approve, and no way to name an account at all except as a number to compare against. So a program cannot tax you, cannot freeze you, cannot touch your balance and cannot stop you selling. Not because it is forbidden to. Because the instruction it would need was never given a number.
the exit is not programmable //the load bearing promise, stated in the token
| claim | why |
|---|---|
| There is no tax. | The internal move function moves the number you asked it to move. No branch in it takes a cut, no address gets a share and there is no fee variable to set. |
| There is no pause. | No blacklist, no cooldown, no maximum wallet, no maximum transaction. The only gate is a one way open flag, and the function that sets it deletes its own caller in the same breath. |
| There is no owner. | Not renounced later, not transferred to a dead address. There is no owner variable and no modifier anywhere that admits one. |
| There is no mint. | The function was never written, and a function that was never written cannot be switched on by anybody. |
| The machine has no reach into the token. | A program has no opcode that can call an address. It cannot transfer the token, cannot approve it, cannot burn anybody's balance and cannot read anybody's balance except through totalSupply, which it gets handed as a number. |
| Supply only falls. | The hook burns the fuel it takes. There is no unburn, so supply is monotonically non increasing from the instant of deployment. |
what is not authenticated //the one that gets programs robbed
- CALLER (0x33)
- whoever called the PoolManager. Authenticated in the only sense that matters: the PoolManager was there and it is the address it saw. IF THE BUYER USED A ROUTER OR AN AGGREGATOR, CALLER IS THAT ROUTER, not the person. There is no way for the machine to see past a router.
- ACCOUNT (0xb7)
- a field in the hookData. ANYBODY CAN PUT ANYTHING IN IT. It exists because a program that wants to credit a human being needs somewhere to read one from when the swap came through a router.
- the rule
- a program may use ACCOUNT to address a result AT somebody. It must never use ACCOUNT as permission to take something FROM somebody. The only cost of a false claim is that the claimant paid for the swap and the fuel and gave the result away, which is why this is a safe thing to offer and an unsafe thing to trust.
the sharp edges //every one of them, including the ones that are not flattering
- A buggy program makes your buy fail and you pay the gas. Any failure anywhere reverts everything: the program, the fuel burn, and the swap that carried it. In exchange there is no state in which the swap landed and the program did not. That is the trade and it is not hidden anywhere on this site.
- BOUGHT is the amount BEFORE fuel. The buyer keeps less than BOUGHT reports. It cannot be otherwise: the fuel bill is not known until the program has finished running, and the program is what is running. A program that credits somebody with BOUGHT is over counting by the fuel bill.
- TICK is two complement. A negative tick reads as a very large unsigned number. Compare it with SLT and SGT, never with LT and GT. Nothing in the machine warns you.
- What the entry program RETURNs is not visible on chain. The hook takes the fuel figure and drops the returned words. The way to get a value out of a program and into a block is SSTORE or LOG. RETURN is for reading over an eth_call and for handing a result back to a calling program.
- A program that looks random is not random. The example die takes ACCOUNT and the block number. ACCOUNT is a free field in the hookData and the block number is known while you are building the transaction, so a caller can try values until the result suits them, at no cost, before sending anything. Nothing in this machine produces unpredictable numbers. Treat the example as a demonstration of HASH and MOD and not as a game.
- A CALL cannot be caught. There is no status flag and no try. If the callee reverts, everything reverts. Programs that want to be composed have to be written not to fail.
- Programs can reenter each other. Any program may call any other at any depth below 4, so a program that calls out and is called back can read its own storage in the middle of updating it. The machine does not stop it. DEPTH and FROM exist so that a program can refuse it, and a program that must not be reentered has to say so itself. The machine panel has the worked sequence.
- A fuelLimit above 2 560 does not say so. The caps are priced against the limit before the machine is reached, and the machine is the only thing that compares the limit with MAX_FUEL. So an over large limit comes back as a cap error quoting an enormous burn, or as an arithmetic failure if it is large enough, rather than as the limit error that would tell you what you did. Keep fuelLimit at or below 2 560 and the question does not arise.
- fuelLimit is an admission test as well as a ceiling. Both caps are priced against the LIMIT before the program runs, so a generous limit with a tight maxBurn is refused even when the program would have spent a fraction of it. That habit comes straight from gas limits and it does not transfer.
- Authorship of a spawned program is whatever the hookData claimed. A program written by SPAWN is credited to the ACCOUNT field, falling back to whoever called the PoolManager. ACCOUNT is not authenticated, so authorship is a signature on a drawing rather than a deed. It carries no rights of any kind and nothing anywhere reads it for permission.
- An install is for ever, including a broken one. There is no upgrade path, no admin and no way to withdraw a program. A bug ships permanently and the fix is a new id that callers have to choose.
- Fuel is not gas, and gas is the bigger number. Fuel bounds what the machine will do. The transaction still needs real gas on top. Measured: a fuel unit costs about 218.32 gas to execute and is priced at 400 000 000 000 wei, so at 20 gwei the gas is about 10.9 times the burn. The fuel table has the whole of it.
- There is no audit and nothing is deployed. No pool exists, no address exists and nothing on this site has been reviewed by anybody but the people who wrote it.
every limit in one table //
| name | value | where |
|---|---|---|
| MAX_FUEL | 2 560 | the machine |
| BASE_COST | 4 | the machine |
| C_EXP, C_EXP_BYTE | 16, 64 | the machine |
| STACK_MAX | 64 | the machine |
| MEM_WORDS | 64 | the machine |
| MAX_DEPTH | 4 | the machine |
| MAX_FRAMES | 16 | the machine |
| MAX_ARGS | 8 | the machine |
| SPAWN_MAX_WORDS | 16 | the machine |
| MAX_CODE | 2 048 | the program store |
| WEI_PER_FUEL | 400 000 000 000 | the hook |
| MAX_FUEL_SHARE_BPS | 5 000 | the hook |
| FEE | 10 000 | the hook |
| TICK_SPACING | 200 | the hook |
| INITIAL_SUPPLY | 1 000 000 000 whole | the token |
| decimals | 18 | the token |
Assembler
The machine, in this page, offline. Type assembly and the bytes, the jump destination bitmap, the fuel total and the token burn appear as you type. Nothing here calls out to anything: the interpreter running below is the same source the repository's assertions are run against.
One instruction to a line. A semicolon starts a comment. name: declares a label and @name is its address, which must be pushed with PUSH2. Immediates take decimal or 0x hex.
VALUE is the wei the buy spends and it is the only number the fuel charge needs. BOUGHT is optional: set it to see the burn in token units as well as as a share. Leave maxBurn empty to ignore that cap.
The program set
The first 10. Every one is assembled from the source printed beside it, so the bytes on this page and the bytes that would go on chain come from the same string. The fuel figures are measured by running them, not estimated.
| program | bytes | fuel | stores | smallest buy | share at 0.1 ETH | what it is for |
|---|---|---|---|---|---|---|
| GRIND | 512 | 2 560 | 0 | 0.002048 | 1.0240% | the worst case in gas, and it is the dullest program in the set |
| TALLY | 24 | 647 | 2 | 0.0005176 | 0.2588% | counts entries and records who each one was |
| ROLL | 18 | 79 | 0 | 0.0000632 | 0.0316% | a die, and a warning about what a die is not |
| CLAIM | 37 | 394 | 1 | 0.0003152 | 0.1576% | first claim on a key keeps it, and a second one reverts the buy |
| LEDGER | 36 | 991 | 3 | 0.0007928 | 0.3964% | running totals of wei in, tokens out and entries |
| FIB | 60 | 1 539 | 0 | 0.0012312 | 0.6156% | a real loop, so the fuel meter is legible |
| DOUBLE | 14 | 47 | 0 | 0.0000376 | 0.0188% | a component, small enough to be called rather than read |
| RELAY | 24 | 188 | 0 | 0.0001504 | 0.0752% | composition: one program calling another by id |
| DEEP | 12 | TooDeep | 0 | nil | nil | calls itself for ever, and is stopped by the depth cap |
| SEED | 51 | 1 151 | 0 | 0.0009208 | 0.4604% | writes a program, which is the SPAWN opcode doing its whole job |
Smallest buy is the size below which the fuel bill is more than 50 per cent of the tokens delivered and the hook reverts, assuming a fuelLimit set exactly to what the program spends. It is derived from the fuel figure and nothing else, because the token price cancels out of both sides. DEEP has no figures because DEEP never finishes: it is in the set to show what the depth cap does.
JUMPDEST, 512 times. The cheapest legal instruction repeated until the tank is exactly empty, which makes it the largest number of instructions one swap can ever run and therefore the worst case in gas.
- arguments
- none
- returns
- nothing
- length
- 512 bytes, 512 instructions executed
- storage written
- 0 keys
- fuel
- 2 560 of 2 560, which is 0.001024 ether of compute
- smallest buy
- 0.002048 ETH, below which the share cap reverts the swap
One line, JUMPDEST, repeated 512 times. It is not printed here because printing it would be 512 identical lines. The workbench button below loads it in full.
5b repeated 512 times
Counts entries and writes down who the nth entrant was. Two stores.
- arguments
- none
- returns
- the entry number this buyer got
- length
- 24 bytes, 17 instructions executed
- storage written
- 2 keys
- fuel
- 647 of 2 560, which is 0.0002588 ether of compute
- smallest buy
- 0.0005176 ETH, below which the share cap reverts the swap
PUSH1 0x00 ; key 0 SLOAD ; n PUSH1 0x01 ADD ; n+1 DUP1 ; n+1 n+1 PUSH1 0x00 SSTORE ; store[0] = n+1 ACCOUNT ; n+1 who SWAP1 ; who n+1 SSTORE ; store[n+1] = who PUSH1 0x00 SLOAD PUSH1 0x00 MSTORE PUSH1 0x01 PUSH1 0x00 RETURN
60005460010180600055b7905560005460005260016000f3
000000
0000000060005460010180600055b7905560005460005260016000f3
A die from the entrant and the block. Touches no storage at all.
- arguments
- none
- returns
- a number from 1 to 6
- length
- 18 bytes, 13 instructions executed
- storage written
- 0 keys
- fuel
- 79 of 2 560, which is 0.0000316 ether of compute
- smallest buy
- 0.0000632 ETH, below which the share cap reverts the swap
ACCOUNT NUMBER HASH PUSH1 0x06 SWAP1 MOD ; 0..5 PUSH1 0x01 ADD ; 1..6 PUSH1 0x00 MSTORE PUSH1 0x01 PUSH1 0x00 RETURN
b743206006900660010160005260016000f3
000000
00000000b743206006900660010160005260016000f3
First claim on a key keeps it. A second claimant is REVERTed, which takes the whole buy with it. A caller who names no account is refused before anything is written, because ACCOUNT has no fallback and a zero owner would read as unclaimed to the next caller.
- arguments
- key (shown with 42)
- returns
- the key, once it is yours
- length
- 37 bytes, 22 instructions executed
- storage written
- 1 key
- fuel
- 394 of 2 560, which is 0.0001576 ether of compute
- smallest buy
- 0.0003152 ETH, below which the share cap reverts the swap
ACCOUNT PUSH2 @named JUMPI PUSH1 0x02 ; reason 2: no account given REVERT named: JUMPDEST PUSH1 0x00 ARG ; key DUP1 SLOAD ; key owner ISZERO PUSH2 @free JUMPI PUSH1 0x01 ; reason 1: taken REVERT free: JUMPDEST ACCOUNT ; key who SWAP1 ; who key SSTORE PUSH1 0x00 ARG PUSH1 0x00 MSTORE PUSH1 0x01 PUSH1 0x00 RETURN
b7610008576002fd5b6000b5805415610016576001fd5bb790556000b560005260016000f3
0001400000
000001400000b7610008576002fd5b6000b5805415610016576001fd5bb790556000b560005260016000f3
Running totals: wei in, tokens out, entries. Three stores, and the most expensive of the five per instruction.
- arguments
- none
- returns
- the entry count
- length
- 36 bytes, 25 instructions executed
- storage written
- 3 keys
- fuel
- 991 of 2 560, which is 0.0003964 ether of compute
- smallest buy
- 0.0007928 ETH, below which the share cap reverts the swap
PUSH1 0x00 SLOAD VALUE ADD PUSH1 0x00 SSTORE PUSH1 0x01 SLOAD BOUGHT ADD PUSH1 0x01 SSTORE PUSH1 0x02 SLOAD PUSH1 0x01 ADD PUSH1 0x02 SSTORE PUSH1 0x02 SLOAD PUSH1 0x00 MSTORE PUSH1 0x01 PUSH1 0x00 RETURN
6000543401600055600154b00160015560025460010160025560025460005260016000f3
0000000000
0000000000006000543401600055600154b00160015560025460010160025560025460005260016000f3
Fibonacci to argument zero, with a real loop. The cost is linear in the argument, which is what makes the fuel meter legible.
- arguments
- n (shown with 10)
- returns
- fib(n)
- length
- 60 bytes, 270 instructions executed
- storage written
- 0 keys
- fuel
- 1 539 of 2 560, which is 0.0006156 ether of compute
- smallest buy
- 0.0012312 ETH, below which the share cap reverts the swap
PUSH1 0x00 ; a = 0 PUSH1 0x00 MSTORE PUSH1 0x01 ; b = 1 PUSH1 0x01 MSTORE PUSH1 0x00 ARG ; n = arg0 PUSH1 0x02 MSTORE loop: JUMPDEST PUSH1 0x02 MLOAD ISZERO PUSH2 @done JUMPI PUSH1 0x00 MLOAD ; a PUSH1 0x01 MLOAD ; a b ADD ; t = a + b PUSH1 0x01 MLOAD ; t b PUSH1 0x00 MSTORE ; a = b PUSH1 0x01 MSTORE ; b = t PUSH1 0x01 PUSH1 0x02 MLOAD ; 1 n SUB ; n - 1 PUSH1 0x02 MSTORE PUSH2 @loop JUMP done: JUMPDEST PUSH1 0x01 PUSH1 0x00 RETURN
600060005260016001526000b56002525b600251156100365760005160015101600151600052600152600160025103600252610010565b60016000f3
0000010000004000
000000010000004000600060005260016001526000b56002525b600251156100365760005160015101600151600052600152600160025103600252610010565b60016000f3
Doubles its first argument. It exists to be called by RELAY, and it is the smallest thing a component can be.
- arguments
- x (shown with 21)
- returns
- x twice over
- length
- 14 bytes, 9 instructions executed
- storage written
- 0 keys
- fuel
- 47 of 2 560, which is 0.0000188 ether of compute
- smallest buy
- 0.0000376 ETH, below which the share cap reverts the swap
PUSH1 0x00 ARG PUSH1 0x02 MUL PUSH1 0x00 MSTORE PUSH1 0x01 PUSH1 0x00 RETURN
6000b560020260005260016000f3
0000
0000006000b560020260005260016000f3
Calls the program named in argument zero, passing argument one, and returns what it said. This is composition: one program using another as a component, with neither of them ever being an address.
- arguments
- id, x (shown with 7, 21)
- returns
- whatever the callee returned, one word
- length
- 24 bytes, 24 instructions executed
- storage written
- 0 keys
- fuel
- 188 of 2 560, which is 0.0000752 ether of compute
- smallest buy
- 0.0001504 ETH, below which the share cap reverts the swap
PUSH1 0x01 ARG PUSH1 0x00 MSTORE ; mem[0] = arg1 PUSH1 0x01 ; retMax PUSH1 0x01 ; retOff PUSH1 0x01 ; argWords PUSH1 0x00 ; argOff PUSH1 0x00 ARG ; id = arg0 CALL POP PUSH1 0x01 PUSH1 0x01 RETURN ; return mem[1]
6001b560005260016001600160006000b5f15060016001f3
000000
000000006001b560005260016001600160006000b5f15060016001f3
Calls itself for ever. It cannot: the depth cap stops it, and it stops it by reverting the swap rather than by returning a flag.
- arguments
- none
- returns
- nothing. It never gets that far.
- length
- 12 bytes
- result
- it never finishes. The machine stops it with TooDeep, which reverts the swap.
PUSH1 0x00 PUSH1 0x00 PUSH1 0x00 PUSH1 0x00 ADDRESS CALL POP STOP
600060006000600030f15000
0000
000000600060006000600030f15000
Writes a program. The child is ten bytes that return 42, padded to one word with STOPs, which is what SPAWN word addressing means in practice.
- arguments
- none
- returns
- the id the child was given
- length
- 51 bytes, 12 instructions executed
- storage written
- 0 keys
- fuel
- 1 151 of 2 560, which is 0.0004604 ether of compute
- smallest buy
- 0.0009208 ETH, below which the share cap reverts the swap
PUSH32 0x602a60005260016000f300000000000000000000000000000000000000000000 PUSH1 0x00 MSTORE PUSH1 0x01 ; tag PUSH1 0x01 ; words PUSH1 0x00 ; offset SPAWN PUSH1 0x00 MSTORE PUSH1 0x01 PUSH1 0x00 RETURN
7f602a60005260016000f300000000000000000000000000000000000000000000600052600160016000f060005260016000f3
00000000000000
00000000000000007f602a60005260016000f300000000000000000000000000000000000000000000600052600160016000f060005260016000f3
The call slip
The exact bytes a buy has to carry to run a program, built from whatever the workbench is holding. Change anything on the assembler panel and this changes with it.
Ids are issued in sequence from 1 by the program store. Nothing is deployed, so this number is yours to imagine.
the encoding //the only definition anybody should use
The hook carries a pure encode function that builds this, and it is the definition. Four bytes of magic, then a plain abi.encode of the program, the fuel limit, the burn ceiling, the claimed account and the argument words. Nothing is packed, nothing is bit shifted and there is no version byte, because there is no second version: the machine has no upgrade path.
before you send one //four things that will cost you a transaction
- It must be an exact input buy. An exact output buy carrying the magic reverts. The reason is on the door panel and it is a v4 mechanic rather than a preference.
- The pool wants tickSpacing 200. The Uniswap app defaults to 60 and the pool will refuse until that is corrected by hand.
- fuelLimit is a ceiling, not a charge. Setting it to the full 2 560 costs nothing extra if the program only spends a tenth of it. Setting it too low reverts the buy.
- maxBurn of zero means no burn is acceptable. Any program that spends any fuel at all will then revert the transaction. Set it to what you are willing to lose, in token units.
reading a World from outside //without spending anything
The machine carries a preview entry point that runs the real interpreter against real world state and then reverts unconditionally, so over an eth_call it is an exact answer and as a transaction it is a no op that wasted gas. SPAWN is refused under preview, because a create inside a call that is going to revert would burn gas to no purpose and report an id that will never exist. Two more view functions read a program's storage directly, one for a scattered set of keys and one for a contiguous run.