Skip to content

Instantly share code, notes, and snippets.

@0xChqrles
Last active August 8, 2024 19:39
Show Gist options
  • Save 0xChqrles/35b1660778cca7930294830c56618959 to your computer and use it in GitHub Desktop.
Save 0xChqrles/35b1660778cca7930294830c56618959 to your computer and use it in GitHub Desktop.

Deploying a smart contract on Starknet (eg. OZ ETH Account)

scarb setup

  • Install scarb latest version:
curl --proto '=https' --tlsv1.2 -sSf https://docs.swmansion.com/scarb/install.sh | sh

RPC setup

shell setup

  • 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

sncast setup

  • 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, add 0 after the 0x 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

Contract declaration

For this example we'll declare the OZ eth account

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

Contract Deployment

  • 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

Testing

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à 🙃

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment