Cairo Deployment Scripts
Overview
⚠️⚠️⚠️ Highly experimental code, a subject to change ⚠️⚠️⚠️
Starknet Foundry cast can be used to run deployment scripts written in Cairo, using script run
subcommand.
It aims to provide similar functionality to Foundry's forge script
.
To start writing a deployment script in Cairo just add sncast_std
as a dependency to you scarb package and make sure to
have a main
function in the module you want to run. sncast_std
docs can be found here.
Please note that sncast script
is in development. While it is already possible to declare, deploy, invoke and call
contracts from within Cairo, its interface, internals and feature set can change rapidly each version.
⚠️⚠️ By default, the nonce for each transaction is being taken from the pending block ⚠️⚠️
Some RPC nodes can be configured with higher poll intervals, which means they may return "older" nonces in pending blocks, or even not be able to obtain pending blocks at all. This might be the case if you get an error like "Invalid transaction nonce" when running a script, and you may need to manually set both nonce and max_fee for transactions.
Example:
let declare_result = declare( "Map", FeeSettings::Eth(EthFeeSettings { max_fee: Option::Some(max_fee) }), Option::Some(nonce) ) .expect('declare failed');
Some of the planned features that will be included in future versions are:
- dispatchers support
- logging
- account creation/deployment
- multicall support
- dry running the scripts
and more!
State file
By default, when you run a script a state file containing information about previous runs will be created. This file can later be used to skip making changes to the network if they were done previously.
To determine if an operation (a function like declare, deploy or invoke) has to be sent to the network, the script will first check if such operation with given arguments already exists in state file. If it does, and previously ended with a success, its execution will be skipped. Otherwise, sncast will attempt to execute this function, and will write its status to the state file afterwards.
To prevent sncast from using the state file, you can set the --no-state-file flag.
A state file is typically named in a following manner:
{script name}_{network name}_state.json
Suggested directory structures
As sncast scripts are just regular scarb packages, there are multiple ways to incorporate scripts into your existing scarb workspace. Most common directory structures include:
1. scripts
directory with all the scripts in the same workspace with cairo contracts (default for sncast script init
)
$ tree
Output:
.
├── scripts
│ └── my_script
│ ├── Scarb.toml
│ └── src
│ ├── my_script.cairo
│ └── lib.cairo
├── src
│ ├── my_contract.cairo
│ └── lib.cairo
└── Scarb.toml
📝 Note You should add
scripts
tomembers
field in your top-level Scarb.toml to be able to run the script from anywhere in the workspace - otherwise you will have to run the script from within its directory. To learn more consult Scarb documentation.
You can also have multiple scripts as separate packages, or multiple modules inside one package, like so:
1a. multiple scripts in one package
$ tree
Output:
.
├── scripts
│ └── my_script
│ ├── Scarb.toml
│ └── src
│ ├── my_script1.cairo
│ ├── my_script2.cairo
│ └── lib.cairo
├── src
│ ├── my_contract.cairo
│ └── lib.cairo
└── Scarb.toml
1b. multiple scripts as separate packages
$ tree
Output:
.
├── scripts
│ ├── Scarb.toml
│ ├── first_script
│ │ ├── Scarb.toml
│ │ └── src
│ │ ├── first_script.cairo
│ │ └── lib.cairo
│ └── second_script
│ ├── Scarb.toml
│ └── src
│ ├── second_script.cairo
│ └── lib.cairo
├── src
│ ├── my_contract.cairo
│ └── lib.cairo
└── Scarb.toml
1c. single script with flat directory structure
$ tree
Output:
.
├── Scarb.toml
├── scripts
│ ├── Scarb.toml
│ └── src
│ ├── my_script.cairo
│ └── lib.cairo
└── src
└── lib.cairo
2. scripts disjointed from the workspace with cairo contracts
$ tree
Output:
.
├── Scarb.toml
└── src
├── lib.cairo
└── my_script.cairo
In order to use this directory structure you must set any contracts you're using as dependencies in script's Scarb.toml,
and override build-external-contracts
property to build those contracts. To learn more consult Scarb documentation.
This setup can be seen in action in Full Example below.
Examples
Initialize a script
To get started, a deployment script with all required elements can be initialized using the following command:
$ sncast script init my_script
For more details, see init command.
📝 Note To include a newly created script in an existing workspace, it must be manually added to the members list in the
Scarb.toml
file, under the defined workspace. For more detailed information about workspaces, please refer to the Scarb documentation.
Minimal Example (Without Contract Deployment)
This example shows how to call an already deployed contract. Please find full example with contract deployment here.
use starknet::ContractAddress;
use sncast_std::{call, CallResult};
// A real contract deployed on Sepolia network
const CONTRACT_ADDRESS: felt252 =
0x07e867f1fa6da2108dd2b3d534f1fbec411c5ec9504eb3baa1e49c7a0bef5ab5;
fn main() {
let call_result = call(
CONTRACT_ADDRESS.try_into().unwrap(), selector!("get_greeting"), array![]
)
.expect('call failed');
assert(*call_result.data[1] == 'Hello, Starknet!', *call_result.data[1]);
println!("{:?}", call_result);
}
The script should be included in a Scarb package. The directory structure and config for this example looks like this:
$ tree
Output:
.
├── src
│ ├── my_script.cairo
│ └── lib.cairo
└── Scarb.toml
[package]
name = "my_script"
version = "0.1.0"
[dependencies]
starknet = ">=2.8.0"
sncast_std = "0.33.0"
To run the script, do:
$ sncast \
script run my_script
--url https://starknet-sepolia.public.blastapi.io/rpc/v0_7
Output:
CallResult { data: [0, 96231036770510887841935600920878740513, 16] }
command: script run
status: success
Full Example (With Contract Deployment)
This example script declares, deploys and interacts with an example MapContract
:
#[starknet::interface]
pub trait IMapContract<State> {
fn put(ref self: State, key: felt252, value: felt252);
fn get(self: @State, key: felt252) -> felt252;
}
#[starknet::contract]
pub mod MapContract {
use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess};
#[storage]
struct Storage {
storage: Map<felt252, felt252>,
}
#[abi(embed_v0)]
impl MapContractImpl of super::IMapContract<ContractState> {
fn put(ref self: ContractState, key: felt252, value: felt252) {
self.storage.write(key, value);
}
fn get(self: @ContractState, key: felt252) -> felt252 {
self.storage.read(key)
}
}
}
We prepare a script:
use sncast_std::{
declare, deploy, invoke, call, DeclareResult, DeclareResultTrait, DeployResult, InvokeResult,
CallResult, get_nonce, FeeSettings, EthFeeSettings
};
fn main() {
let max_fee = 999999999999999;
let salt = 0x3;
let declare_nonce = get_nonce('latest');
let declare_result = declare(
"MapContract",
FeeSettings::Eth(EthFeeSettings { max_fee: Option::Some(max_fee) }),
Option::Some(declare_nonce)
)
.expect('map declare failed');
let class_hash = declare_result.class_hash();
let deploy_nonce = get_nonce('pending');
let deploy_result = deploy(
*class_hash,
ArrayTrait::new(),
Option::Some(salt),
true,
FeeSettings::Eth(EthFeeSettings { max_fee: Option::Some(max_fee) }),
Option::Some(deploy_nonce)
)
.expect('map deploy failed');
assert(deploy_result.transaction_hash != 0, deploy_result.transaction_hash);
let invoke_nonce = get_nonce('pending');
let invoke_result = invoke(
deploy_result.contract_address,
selector!("put"),
array![0x1, 0x2],
FeeSettings::Eth(EthFeeSettings { max_fee: Option::Some(max_fee) }),
Option::Some(invoke_nonce)
)
.expect('map invoke failed');
assert(invoke_result.transaction_hash != 0, invoke_result.transaction_hash);
let call_result = call(deploy_result.contract_address, selector!("get"), array![0x1])
.expect('map call failed');
assert(call_result.data == array![0x2], *call_result.data.at(0));
}
The script should be included in a Scarb package. The directory structure and config for this example looks like this:
$ tree
Output:
.
├── contracts
│ ├── Scarb.toml
│ └── src
│ └── lib.cairo
└── scripts
├── Scarb.toml
└── src
├── lib.cairo
└── map_script.cairo
[package]
name = "map_script"
version = "0.1.0"
[dependencies]
starknet = ">=2.8.0"
sncast_std = "0.33.0"
map = { path = "../contracts" }
[lib]
sierra = true
casm = true
[[target.starknet-contract]]
build-external-contracts = [
"map::MapContract"
]
Please note that map
contract was specified as the dependency. In our example, it resides in the filesystem. To generate the artifacts for it that will be accessible from the script you need to use the build-external-contracts
property.
To run the script, do:
$ sncast \
--account example_user \
script run map_script \
--url https://starknet-sepolia.public.blastapi.io/rpc/v0_7
Output:
Class hash of the declared contract: 685896493695476540388232336434993540241192267040651919145140488413686992233
...
Deployed the contract to address: 2993684914933159551622723238457226804366654523161908704282792530334498925876
...
Invoke tx hash is: 2455538849277152825594824366964313930331085452149746033747086127466991639149
Call result: [2]
command: script run
status: success
As an idempotency feature is turned on by default, executing the same script once again ends with a success
and only call
functions are being executed (as they do not change the network state):
$ sncast \
--account example_user \
script run map_script \
--url https://starknet-sepolia.public.blastapi.io/rpc/v0_7
Output:
Class hash of the declared contract: 1922774777685257258886771026518018305931014651657879651971507142160195873652
Deployed the contract to address: 3478557462226312644848472512920965457566154264259286784215363579593349825684
Invoke tx hash is: 1373185562410761200747829131886166680837022579434823960660735040169785115611
Call result: [2]
command: script run
status: success
whereas, when we run the same script once again with --no-state-file
flag set, it fails (as the Map
contract is already deployed):
$ sncast \
--account example_user \
script run map_script \
--url https://starknet-sepolia.public.blastapi.io/rpc/v0_7 \
--no-state-file
Output:
command: script run
message:
0x6d6170206465706c6f79206661696c6564 ('map deploy failed')
status: script panicked
Error handling
Each of declare
, deploy
, invoke
, call
functions return Result<T, ScriptCommandError>
, where T
is a corresponding response struct.
This allows for various script errors to be handled programmatically.
Script errors implement Debug
trait, allowing the error to be printed to stdout.
Minimal example with assert!
and println!
use starknet::ContractAddress;
use sncast_std::{call, CallResult};
// Some nonexistent contract
const CONTRACT_ADDRESS: felt252 = 0x2137;
fn main() {
// This call fails
let call_result = call(
CONTRACT_ADDRESS.try_into().unwrap(), selector!("get_greeting"), array![]
);
// Make some assertion
assert!(call_result.is_err());
// Print the result error
println!("Received error: {:?}", call_result.unwrap_err());
}
More on deployment scripts errors here.