On January 30th the ancient Stellar blockchain is getting "Soroban" smart contract support and thus entering a brave new world of arbitrary code execution. As someone who's been involved with Stellar since 2014 I could not be more excited. 10 years. TEN YEARS!! and we're finally being gifted the entrepreneurial utopia of decentralized compute on the blockchain for real world.
This January 30th day kicks off a Phase 0 of several ramp up phases as Soroban receives more and more of the block interest as the network ensures a safe and stable rollout of the madness that is Soroban. These phases each introduce their own set of resource limitations on both the network from and on the protocol itself. This is the topic of interest for this post. Phase 0 is really resource constrained. (See InitialSorobanNetworkConfig
).
I've been waiting for this day for 10 years though so you'd better believe the only question on my mind was "okay yeah sure, it's limited, but is it possible to actually upload and invoke a contract in Phase 0?"
Answer: Yes
Step 1: Stand up a Phase 0 testing environment
The first challenge I was faced with was how to simulate a Phase 0 network limitations. Both Futurenet and Testnet are currently running Phase 1 so it's actually non-trivial to test for all the limits on a network that allows for significantly more than Phase 0.
Enter the Docker quickstart! Turns out some genius SDF engineer added a --limits
flag with a default
setting which was exactly what I was looking for.
docker run --rm -i \
-p "8000:8000" \
--name stellar \
stellar/quickstart:testing \
--local \
--limits default \
--enable-soroban-rpc
And we're off to the races with a local Phase 0 network!
Step 2: Develop a really small contract
Next on the list is sorting out creating a really small contract. Small in this instance means small in every way. Small final contract size. Small invocation response size. Small number of resources consumed.
This particular step led me deep down a rabbit hole of writing WASM by hand. See it turns out that while Soroban is wrapped all up in Rust it's actually running WebAssembly behind the scenes. What this means is you can ditch the Rust in the end and just write WASM code. This would be madness for a contract of any substance, but for our purposes trying to get to the absolute smallest invocable contract this should be fine.
Let's start with the smallest Rust contract code I can think of:
#![no_std]
use soroban_sdk::{contract, contractimpl, Symbol, symbol_short};
#[contract]
pub struct Contract;
#[contractimpl]
impl Contract {
pub fn call() -> Symbol {
symbol_short!("hi")
}
}
Once we build and optimize that contract:
soroban contract build
soroban contract optimize --wasm phase0.wasm
We get back a 300 byte contract. That's really small and actually probably entirely tiny enough. However, I'm obsessive, and want to be able to claim something as bold and outrageous and having deployed and invoked the world's smallest smart contract and at this point I'm not convinced 300 is the absolute floor. So I dig in deeper.
What I really need at this point is the ability to observe and dissect my raw WASM file and see what it's made of. Enter wabt and it's suite of WASM dissection and manipulation tools! With that installed I can begin to inspect my 300 byte contract to see what it contains.
wasm-objdump phase0.wasm -xs
phase0-slim.wasm: file format wasm 0x1
Section Details:
Type[2]:
- type[0] () -> i64
- type[1] () -> nil
Function[2]:
- func[0] sig=0 <call>
- func[1] sig=1 <_>
Memory[1]:
- memory[0] pages: initial=16
Global[2]:
- global[0] i32 mutable=0 <__data_end> - init i32=1048576
- global[1] i32 mutable=0 <__heap_base> - init i32=1048576
Export[5]:
- memory[0] -> "memory"
- func[0] <call> -> "call"
- func[1] <_> -> "_"
- global[0] -> "__data_end"
- global[1] -> "__heap_base"
Code[2]:
- func[0] size=6 <call>
- func[1] size=2 <_>
Custom:
- name: "contractspecv0"
Custom:
- name: "contractenvmetav0"
Custom:
- name: "contractmetav0"
Contents of section Type:
000000a: 0260 0001 7e60 0000 .`..~`..
Contents of section Function:
0000014: 0200 01 ...
Contents of section Memory:
0000019: 0100 10 ...
Contents of section Global:
000001e: 027f 0041 8080 c000 0b7f 0041 8080 c000 ...A.......A....
000002e: 0b .
Contents of section Export:
0000031: 0506 6d65 6d6f 7279 0200 0463 616c 6c00 ..memory...call.
0000041: 0001 5f00 010a 5f5f 6461 7461 5f65 6e64 .._...__data_end
0000051: 0300 0b5f 5f68 6561 705f 6261 7365 0301 ...__heap_base..
Contents of section Code:
0000063: 0206 0042 8edc 2d0b 0200 0b ...B..-....
Contents of section Custom:
0000070: 0e63 6f6e 7472 6163 7473 7065 6376 3000 .contractspecv0.
0000080: 0000 0000 0000 0000 0000 0463 616c 6c00 ...........call.
0000090: 0000 0000 0000 0100 0000 11 ...........
Contents of section Custom:
000009d: 1163 6f6e 7472 6163 7465 6e76 6d65 7461 .contractenvmeta
00000ad: 7630 0000 0000 0000 0014 0000 0000 v0............
Contents of section Custom:
00000bd: 0e63 6f6e 7472 6163 746d 6574 6176 3000 .contractmetav0.
00000cd: 0000 0000 0000 0572 7376 6572 0000 0000 .......rsver....
00000dd: 0000 0631 2e37 342e 3100 0000 0000 0000 ...1.74.1.......
00000ed: 0000 0872 7373 646b 7665 7200 0000 2f32 ...rssdkver.../2
00000fd: 302e 302e 3323 3933 6230 3965 3432 6534 0.0.3#93b09e42e4
000010d: 6566 6138 3431 6362 6430 3334 6330 6266 efa841cbd034c0bf
000011d: 6630 6463 3336 3237 3635 3038 3663 00 f0dc362765086c.
Probably way more than we need but whatever. I found the Contents of section {x}:
data particularly interesting. Especially the Custom
sections. There's a lot of bytes in the contractmetav0
section. There's also some functions and exports I thought may be unnecessary for such an otherwise simple and relatively useless contract. But we're after every possible size reduction possible so let's start shaving off some of this potential cruft and rebuilding the WASM contract back up section by section.
Before we do that let's take a look at the actual code portion of the WASM by using the wasm2wat
tool from the wabt
kit
wasm2wat phase0.wasm -o phase0.wat
(module
(type (;0;) (func (result i64)))
(type (;1;) (func))
(func (;0;) (type 0) (result i64)
i64.const 749070)
(func (;1;) (type 1))
(memory (;0;) 16)
(global (;0;) i32 (i32.const 1048576))
(global (;1;) i32 (i32.const 1048576))
(export "memory" (memory 0))
(export "call" (func 0))
(export "_" (func 1))
(export "__data_end" (global 0))
(export "__heap_base" (global 1)))
Okay interesting yeah let's start by blasting off things I don't actually think we need:
(module
(type (;0;) (func (result i64)))
- (type (;1;) (func))
(func (;0;) (type 0) (result i64)
i64.const 749070)
- (func (;1;) (type 1))
- (memory (;0;) 16)
- (global (;0;) i32 (i32.const 1048576))
- (global (;1;) i32 (i32.const 1048576))
- (export "memory" (memory 0))
- (export "call" (func 0))
+ (export "call" (func 0)))
- (export "_" (func 1))
- (export "__data_end" (global 0))
- (export "__heap_base" (global 1)))
Which will leave us with just:
(module
(type (;0;) (func (result i64)))
(func (;0;) (type 0) (result i64)
i64.const 749070)
(export "call" (func 0)))
From there let's reassemble this slimmed down WASM:
wat2wasm phase0.wat -o phase0-slim.wasm
Let's take another look at the wasm:
wasm-objdump phase0-slim.wasm -xs
phase0-slim.wasm: file format wasm 0x1
Section Details:
Type[1]:
- type[0] () -> i64
Function[1]:
- func[0] sig=0 <call>
Export[1]:
- func[0] <call> -> "call"
Code[1]:
- func[0] size=6 <call>
Contents of section Type:
000000a: 0160 0001 7e .`..~
Contents of section Function:
0000011: 0100 ..
Contents of section Export:
0000015: 0104 6361 6c6c 0000 ..call..
Contents of section Code:
000001f: 0106 0042 8edc 2d0b ...B..-.
Alright tight that's looking way smaller! It's actually just 39 bytes! Those custom sections were adding a lot!
Onward to attempt to deploy and invoke this as the world's smallest smart contract!
Step 3: Install, create and invoke the smart contract
In order to get to an invocable contract we actually need to do two things first. The first is to install the WASM code and the second is to create a new contract instance which references that code as it's callable code. Contracts and their code live as two separate entries on the blockchain.
Install
I may enjoy Rust but JS is my true love so I'll be using the Stellar JS SDK to accomplish this step. We won't get too much into the nitty gritty of the specific SDK syntax for submitting transactions. I'll just give you the code and then focus on the more interesting bits for assembling the WASM and installing it onto the Stellar blockchain. I use TypeScript but I also hate build tools so I'll be using Bun in order to keep this whole thing in a single file. After installing Bun I got my project started with bun init
. From there my index.ts
file looks like this.
import { Keypair, Networks, Operation, SorobanRpc, TransactionBuilder, xdr } from '@stellar/stellar-sdk'
import { assembleTransaction } from '@stellar/stellar-sdk/lib/soroban'
const rpc = new SorobanRpc.Server('https://soroban-testnet.stellar.org')
const networkPassphrase = Networks.TESTNET
const keypair = Keypair.fromSecret('SBYUCT7S6RNVRQBAYWJ62AFD7BZAREU763JTYK7YIEFG7K2YCDXCYUK4') // GC7ZSZXYZD4JSMEOHR763LC27Y6W43AZRYVSYWYSR4BSYQTJRWJDTNCI
const pubkey = keypair.publicKey()
const source = await rpc.getAccount(pubkey)
//// Start WASM Assembly
const path = './phase0.wasm'
const file = Bun.file(path)
const wasm = Buffer.from(await file.arrayBuffer())
console.log(wasm.length)
//// End WASM Assembly
//// Start WASM Install
const uploadContractWasmOp = Operation.uploadContractWasm({ wasm })
const uploadContractWasmRes = await sendOp(uploadContractWasmOp)
const wasmHash = uploadContractWasmRes.returnValue!.bytes()
console.log(wasmHash.toString('hex'))
//// End WASM Install
async function sendOp(op: xdr.Operation): Promise<SorobanRpc.Api.GetSuccessfulTransactionResponse> {
...
}
asnyc function sendOp
All you really need to care about is that inside the //// Start WASM Assembly
we are getting the binary of our phase0-slim.wasm
contract and then inside the //// Start WASM Install
we create a Stellar transaction with an uploadContractWasm
operation which will install our WASM contract code onto the blockchain. Let's run it!
bun run index.ts
error: simulation incorrect: {"_parsed":true,"latestLedger":998,"events":[{"_attributes":{"inSuccessfulContractCall":false,"event":{"_attributes":{"ext":{"_switch":0},"type":{"name":"diagnostic","value":2},"body":{"_switch":0,"_arm":"v0","_value":{"_attributes":{"topics":[{"_switch":{"name":"scvSymbol","value":15},"_arm":"sym","_armType":{"_maxLength":32},"_value":{"type":"Buffer","data":[101,114,114,111,114]}},{"_switch":{"name":"scvError","value":2},"_arm":"error","_value":{"_switch":{"name":"sceWasmVm","value":1},"_arm":"code","_value":{"name":"scecInvalidInput","value":2}}}],"data":{"_switch":{"name":"scvString","value":14},"_arm":"str","_armType":{"_maxLength":4294967295},"_value":{"type":"Buffer","data":[99,111,110,116,114,97,99,116,32,109,105,115,115,105,110,103,32,109,101,116,97,100,97,116,97,32,115,101,99,116,105,111,110]}}}}}}}}}],"error":"host invocation failed\n\nCaused by:\n HostError: Error(WasmVm, InvalidInput)\n \n Event log (newest first):\n 0: [Diagnostic Event] topics:[error, Error(WasmVm, InvalidInput)], data:\"contract missing metadata section\"\n \n Backtrace (newest first):\n 0: soroban_env_host::budget::Budget::with_shadow_mode\n 1: soroban_env_host::budget::Budget::with_shadow_mode\n 2: soroban_env_host::host::error::<impl soroban_env_host::host::Host>::err\n 3: soroban_env_host::vm::Vm::new\n 4: soroban_env_host::host::lifecycle::<impl soroban_env_host::host::Host>::upload_contract_wasm\n 5: soroban_env_host::host::frame::<impl soroban_env_host::host::Host>::invoke_function\n 6: preflight::preflight::preflight_invoke_hf_op\n 7: preflight::preflight_invoke_hf_op::{{closure}}\n 8: core::ops::function::FnOnce::call_once{{vtable.shim}}\n 9: preflight::catch_preflight_panic\n 10: _cgo_e635fd1dbb9c_Cfunc_preflight_invoke_hf_op\n at tmp/go-build/cgo-gcc-prolog:106:11\n 11: runtime.asmcgocall\n at ./runtime/asm_amd64.s:872\n \n "}
Sweet licorice fish sticks! Okay looking looking aha.
...
0: [Diagnostic Event] topics:[error, Error(WasmVm, InvalidInput)], data:\"contract missing metadata section\"
...
Looks like maybe one or more of those custom sections aren't as optional as I'd hoped. I'll save you the leg work and just tell you contractspecv0
and contractmetav0
are optional however contractenvmeta
is required. It's worth noting that none of these custom sections are arbitrarily added though. contractspecv0
is essential if you want to have any sort of public facing interface for your contract. Without it any interface would be flying blind on how to interact with the contract. So it's important, but not required. The same goes for that fat contractmetav0
which contains a bunch of versioning information which will be really important for backwards compatibility as Soroban adds new versions in the future. But again we're going for record smashing small so toss it all and leave only the essentials!
The nice thing is that the contractenvmeta
isn't dynamic per contract. As long as your major Soroban environment version stays the same this will remain static. As such here's that custom section in it's array format which we'll then use to concatenate onto our actual contract code.
[0,30,17,99,111,110,116,114,97,99,116,101,110,118,109,101,116,97,118,48,0,0,0,0,0,0,0,20,0,0,0,0]
Cool. Let's modify our //// Start WASM Assembly
now to append this custom slice onto the end of the contract binary.
//// Start WASM Assembly
+ const meta = [0,30,17,99,111,110,116,114,97,99,116,101,110,118,109,101,116,97,118,48,0,0,0,0,0,0,0,20,0,0,0,0]
const path = './phase0.wasm'
const file = Bun.file(path)
- const wasm = Buffer.from(await file.arrayBuffer())
+ const wasm = Buffer.concat([
+ Buffer.from(await file.arrayBuffer()),
+ Buffer.from(meta)
+ ])
console.log(wasm.length)
//// End WASM Assembly
Nice! Now let's try bun run index.ts
again:
71
b713963af003af00228f91eee44bf031d29eb545e7361a59f7c1d731a65d12f4
Okay! We've install a binary of 71 bytes to the Stellar blockchain! That's insane! I wonder though... Could we make it even !?
Side Quest
Create
With the contract code successfully installed we can now move to creating a contract instance which points to this code as it's executable. This contract is then what we'll actually invoke in the end to get our hi
value back.
- import { Keypair, Networks, Operation, SorobanRpc, TransactionBuilder, xdr } from '@stellar/stellar-sdk'
+ import { Address, Keypair, Networks, Operation, SorobanRpc, TransactionBuilder, xdr } from '@stellar/stellar-sdk'
...
//// Start WASM Assembly
...
//// End WASM Assembly
//// Start WASM Install
...
//// End WASM Install
+ //// Start Create Contract
+ const createCustomContractOp = Operation.createCustomContract({
+ wasmHash,
+ address: Address.fromString(pubkey),
+ salt: crypto.getRandomValues(Buffer.alloc(32))
+ })
+
+ const createCustomContractRes = await sendOp(createCustomContractOp)
+ const contract = Address.contract(createCustomContractRes.returnValue!.address().contractId()).toString()
+
+ console.log(contract);
+ //// End Create Contract
async function sendOp(op: xdr.Operation): Promise<SorobanRpc.Api.GetSuccessfulTransactionResponse> {
...
}
With these updates saved let's call our trusty bun run index.ts
again.
71
b713963af003af00228f91eee44bf031d29eb545e7361a59f7c1d731a65d12f4
CBZ2CFNNI7TOOO5LHIN7YJDG4BEX7UO75XCR2NZRZQR2SCUFD2TRJFEV
Awesome! With that there remains only one final step standing between us and sweet sweet victory!
Invoke
Lets add the contract invocation logic.
- import { Address, Keypair, Networks, Operation, SorobanRpc, TransactionBuilder, xdr } from '@stellar/stellar-sdk'
+ import { Address, Keypair, Networks, Operation, SorobanRpc, TransactionBuilder, scValToNative, xdr } from '@stellar/stellar-sdk'
...
//// Start WASM Assembly
...
//// End WASM Assembly
//// Start WASM Install
...
//// End WASM Install
//// Start Create Contract
...
//// End Create Contract
+ //// Start Invoke Contract
+ const invokeContractFunctionOp = Operation.invokeContractFunction({
+ contract,
+ function: 'call',
+ args: []
+ })
+
+ const invokeContractFunctionRes = await sendOp(invokeContractFunctionOp)
+
+ console.log(
+ scValToNative(invokeContractFunctionRes.returnValue!)
+ )
+ /// End Invoke Contract
async function sendOp(op: xdr.Operation): Promise<SorobanRpc.Api.GetSuccessfulTransactionResponse> {
...
}
And now 🤞 let's try one final bun run index.ts
71
b713963af003af00228f91eee44bf031d29eb545e7361a59f7c1d731a65d12f4
CBUR5YZ6AKDMVIYEBCMID5YGUBPBK5NGQMCMJMK3V5MZHH6CR6NHYCL7
hi
Let's goooo!!!
A valid, invocable, 71 byte smart contract. Absolutely insane!
Just one more thing
Big thanks to @heytdep and this friend for helping me understand WASM in deeper ways than I ever thought I wanted to ♥️