- Install scarb latest version:
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh
- Save your Starknet RPC URL in your shell env, example bellow with
bash
Replace API_KEY
with your Voyager API key.
echo export SN_SEPOLIA_RPC_URL=https://rpc.nethermind.io/sepolia-juno/?apikey=[API_KEY] >> ~/.bashrc
echo export SN_MAINNET_RPC_URL=https://rpc.nethermind.io/mainnet-juno/?apikey=[API_KEY] >> ~/.bashrc
- Apply these changes:
source ~/.bashrc
- Install
sncast
:
curl -L https://raw.githubusercontent.com/foundry-rs/starknet-foundry/master/scripts/install.sh | sh
foundryup
- Make sure you have the latest version of the
Universal-Sierra-Compiler
:
curl -L https://raw.githubusercontent.com/software-mansion/universal-sierra-compiler/master/scripts/install.sh | sh
- Create an
sncast
account:
sncast \
-u $SN_SEPOLIA_RPC_URL \
account create \
--name ACCOUNT_NAME
-
Fund the address returned by
sncast
with ETH. If you're on Sepolia, you can use this Faucet. The website might say your address is not valid, to fix this problem, add0
after the0x
until the error disappears. -
Deploy your
sncast
account (with a random big enough max fee parameter):
sncast \
-u $SN_SEPOLIA_RPC_URL \
account deploy \
--name ACCOUNT_NAME \
--max-fee 9999999999999999999
For this example we'll declare the OZ eth account
- clone the OpenZeppelin Contracts for Cairo repository.
git clone https://github.com/OpenZeppelin/cairo-contracts
cd cairo-contracts
- Declare the
EthAccountUpgradeable
contract
sncast \
-u $SN_SEPOLIA_RPC_URL \
--account ACCOUNT_NAME \
declare \
--contract-name EthAccountUpgradeable \
--package openzeppelin_presets
Note that this command will fail if the contract has already been declared by someone else.
But it's actually a good news, it means you won't have to pay fees for the declare transaction and you're still able to get the class hash of your contract by reading the returned error.
For example with this error:
error: Transaction execution error = TransactionExecutionErrorData { transaction_index: 0, execution_error: "failed txn 2614480201368470960237164480826086020563628947677864260753117801885264524787 reason: Class with hash 0x041534fff05642ddbb5778be304be14dd36da40a02feadb6e489f9f749f5c983 is already declared." }
You have the class hash at the end of the message: 0x041534fff05642ddbb5778be304be14dd36da40a02feadb6e489f9f749f5c983
- Now deploy your contract using the class hash previously obtained and the constructor parameters of your choice:
Here, we're deploying an ETH Account, so we need a secp256k1
public key for the constructor parameters.
Let's take this private key as an example: 0x1234
We need to derive the public key from it, there's many ways to do it, so I'll just use starknet.js
:
import { EthSigner } from 'starknet';
const pk = '0x1234'
const signer = new EthSigner(pk)
const pubKey = await signer.getPubKey()
console.log('pubKey:', pubKey)
pubKey: 0x37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1bab96ced90a1b8f9b43a18fc900ff55af2be0e94b90a434fca5b9e226b835024cd
So this long hex number is our public key.
The 64 first characters are Point X coordinate. 64 last characters are Point Y coordinate.
Meaning this is also our public key:
{
x: 0x37a4aef1f8423ca076e4b7d99a8cabff40ddb8231f2a9f01081f15d7fa65c1ba
y: 0xb96ced90a1b8f9b43a18fc900ff55af2be0e94b90a434fca5b9e226b835024cd
}
Now, to understand how to send it as an argument to the constructor, it's important to know that in Cairo, there is only one primitive type: felt252
, and the arguments passed to smart contracts during transactions are therefore a sequence of felt252
values that will be deserialized during execution.
In our example, here's the constructor declaration of our smart contract:
fn constructor(ref self: ContractState, public_key: EthPublicKey)
So we have to provide the public key in the form of a serialized EthPublicKey
.
By navigating through the OZ project, we discover that this type is actually an alias of the starknet::secp256k1::Secp256k1Point
type from the corelib.
Then we have to navigate through the corelib code to find the implementation of the deserialization for this type. So here it is:
fn deserialize(ref serialized: Span<felt252>) -> Option<Secp256k1Point> {
let (x, y) = Serde::<(u256, u256)>::deserialize(ref serialized)?;
secp256k1_new_syscall(x, y).unwrap_syscall()
}
As expected, this function indeed takes a sequence of felt252
as a parameter, or more precisely an array of felt252
, and returns a Secp256k1Point
.
Thus, we can see that we need to send 2 u256
values, corresponding respectively to the x
and y
coordinates of our public key.
If you've been following closely, you've noticed that u256
is not a primary type, so this type must also have a deserialization function, but this time you won't find it in the corelib. It's because the Serde
implementation of the u256
struct is generated by the cairo compiler.
Let's break this down, by looking at the u256
struct from the corelib again.
#[derive(Copy, Drop, Hash, PartialEq, Serde)]
pub struct u256 {
pub low: u128,
pub high: u128,
}
In the first line, we instruct the compiler to automatically generate the implementation of certain traits, including the Serde
trait which is specifically used for serialization/deserialization.
Without delving into the details of the implementation generated by the compiler, the deserialization happens in the order in which the structure's variables are declared. Here, the first felt252
will go into the variable named low
(for the first 128 bits) and the next felt252
will go into the variable named high
(for the last 128 bits).
It’s also worth noting that the types of low and high are not felt252
but u128
, whose deserialization is quite straightforward because the capacity of a u128
is smaller than that of a felt252
. It only takes a single felt252
containing the desired value for our u128
.
So, to summarize, our private key, once serialized, becomes:
[
0x40ddb8231f2a9f01081f15d7fa65c1ba,
0x37a4aef1f8423ca076e4b7d99a8cabff,
0xbe0e94b90a434fca5b9e226b835024cd,
0xb96ced90a1b8f9b43a18fc900ff55af2,
]
We can see it as:
[
x_low,
x_high,
y_low,
y_high,
]
Finaly we can just run this command:
sncast \
-u $SN_SEPOLIA_RPC_URL \
--account main3 \
deploy \
--class-hash 0x041534fff05642ddbb5778be304be14dd36da40a02feadb6e489f9f749f5c983 \
--constructor-calldata 0x40ddb8231f2a9f01081f15d7fa65c1ba 0x37a4aef1f8423ca076e4b7d99a8cabff 0xbe0e94b90a434fca5b9e226b835024cd 0xb96ced90a1b8f9b43a18fc900ff55af2
contract_address: 0x17d4090d2aca3868cf3f1efe8c514f19549b12a8f741be9fb648532b43f20ea
transaction_hash: 0x4be78d133d57cd922b143e5ab4c792c7df74095a13063831e148f8d4990250f
We want to make sure our account is working fine, to do that we can sign a dummy message and verify the validity of the signature using the is_valid_signature
endpoint of our account.
/// Verifies that the given signature is valid for the given hash.
fn is_valid_signature(
self: @ComponentState<TContractState>, hash: felt252, signature: Array<felt252>
) -> felt252
We can see that this endpoint takes a felt252
as a parameter, which will be the hash of our signed message, and a serialized signature, since it is in the form of an array of felt252
.
So, we delve a bit into the code and quickly find that this signature ends up being deserialized into this structure:
#[derive(Copy, Drop, Serde)]
pub struct EthSignature {
pub r: u256,
pub s: u256,
}
We also have to take a look at the corelib again to find that a serialized Array
takes this form [len, ...array]
Now we have enough information we can use starknet.js
again to sign our dummy message and serialize the signature.
import { CallData, encode, EthSigner, num, uint256 } from 'starknet'
import { secp256k1 } from '@noble/curves/secp256k1'
const rawPk = 0x1234
const pk = encode.removeHexPrefix(num.toHex(rawPk)).padStart(64, '0')
const rawMsgHash = 0xdead
const msgHash = encode.removeHexPrefix(num.toHex(rawMsgHash))
const sig = secp256k1.sign(msgHash, pk)
const r = uint256.bnToUint256(sig.r)
const s = uint256.bnToUint256(sig.s)
console.log(CallData.compile([r, s]))
[
'165685140839518363321438618834063533816',
'287413469100223997464671980325212891084',
'340159266648702533932192780375560743812',
'81838037327972279194672694598600500502'
]
Now let's use sncast
one more time to call our contract.
sncast \
-u $SN_SEPOLIA_RPC_URL \
call \
--contract-address 0x17d4090d2aca3868cf3f1efe8c514f19549b12a8f741be9fb648532b43f20ea \
--function is_valid_signature \
--calldata 0xdead 4 165685140839518363321438618834063533816 287413469100223997464671980325212891084 340159266648702533932192780375560743812 81838037327972279194672694598600500502
response: [0x56414c4944]
Using my favorite hex to ASCII converter I can verify that it means 0x56414c4944 == 'VALID'
And if we change a single bit in the signature or the message, we get:
response: [0x0]
No need to use the hex to ASCII converter this time, it's quite clear.
Voilà 🙃