Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SNIP 12 Utilities #935

Merged
merged 21 commits into from
Mar 29, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ba9be3c
feat: add main logic
ericnordelo Mar 5, 2024
b636673
Merge branch 'main' of github.com:OpenZeppelin/cairo-contracts into f…
ericnordelo Mar 5, 2024
3b9387e
feat: docs
ericnordelo Mar 5, 2024
f4c4308
Merge branch 'main' of github.com:OpenZeppelin/cairo-contracts into f…
ericnordelo Mar 11, 2024
7696d94
feat: add Nonces and tests
ericnordelo Mar 11, 2024
dd58df4
docs: finish v1
ericnordelo Mar 11, 2024
9b65cf7
feat: update CHANGELOG
ericnordelo Mar 11, 2024
956cf46
fix: format
ericnordelo Mar 11, 2024
4db173e
Update docs/modules/ROOT/pages/guides/snip12.adoc
ericnordelo Mar 19, 2024
8e1b30d
Update docs/modules/ROOT/pages/guides/snip12.adoc
ericnordelo Mar 19, 2024
b9df2db
Update docs/modules/ROOT/pages/guides/snip12.adoc
ericnordelo Mar 19, 2024
03e7ed5
Update docs/modules/ROOT/pages/guides/snip12.adoc
ericnordelo Mar 19, 2024
32731f1
Update docs/modules/ROOT/pages/guides/snip12.adoc
ericnordelo Mar 19, 2024
81ee1ec
Merge branch 'main' of github.com:OpenZeppelin/cairo-contracts into f…
ericnordelo Mar 19, 2024
abfb363
feat: apply update reviews
ericnordelo Mar 19, 2024
8c9ae15
Merge branch 'feat/snip-12' of github.com:ericnordelo/cairo-contracts…
ericnordelo Mar 19, 2024
e28278b
fix: tests
ericnordelo Mar 19, 2024
5956ab9
fix: linter
ericnordelo Mar 19, 2024
5f8859f
Update docs/modules/ROOT/pages/guides/snip12.adoc
ericnordelo Mar 29, 2024
60eece4
feat: apply review updates
ericnordelo Mar 29, 2024
75d3683
Merge branch 'main' of github.com:OpenZeppelin/cairo-contracts into f…
ericnordelo Mar 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- SNIP12 utilities for on-chain typed messages hash generation (#935)
- Nonces component utility (#935)

### Changed

- Bump scarb to v2.6.3 (#946)
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
** xref:presets.adoc[Presets]
** xref:interfaces.adoc[Interfaces and Dispatchers]
** xref:guides/deployment.adoc[Counterfactual Deployments]
** xref:guides/snip12.adoc[SNIP12 and Typed Messages]
// ** xref:udc.adoc[Universal Deployer Contract]

* Modules
Expand Down
349 changes: 349 additions & 0 deletions docs/modules/ROOT/pages/guides/snip12.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
:snip12: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md[SNIP12]
:eip712: https://eips.ethereum.org/EIPS/eip-712[EIP712]
:erc20: xref:/api/erc20.adoc#ERC20[ERC20]
:erc20-component: xref:/api/erc20.adoc#ERC20Component[ERC20Component]

= SNIP12 and Typed Messages

Similar to {eip712}, {snip12} is a standard for secure off-chain signature verification on Starknet.
It provides a way to hash and sign generic typed structs rather than just strings. When building decentralized
applications, usually you might need to sign a message with complex data. The purpose of signature verification
is then to ensure that the received message was indeed signed by the expected signer, and it hasn't been tampered with.

OpenZeppelin Contracts for Cairo provides a set of utilities to make the implementation of this standard
as easy as possible, and in this guide we will walk you through the process of generating the hashes of typed messages
these utilties for on-chain signature verification. For that, let's build an example with a custom {erc20} contract
ericnordelo marked this conversation as resolved.
Show resolved Hide resolved
adding an extra `transfer_with_signature` method.

WARNING: This is an educational example, and it is not intended to be used in production environments.

== CustomERC20

Let's start with a basic ERC20 contract leveraging the {erc20-component}, and let's add the new function.
Note that some declarations are omitted for brevity. The full example will be available at the end of the guide.

[,javascript]
----
#[starknet::contract]
mod CustomERC20 {
use openzeppelin::token::erc20::ERC20Component;
use starknet::ContractAddress;

component!(path: ERC20Component, storage: erc20, event: ERC20Event);

#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

(...)

#[constructor]
fn constructor(
ref self: ContractState,
initial_supply: u256,
recipient: ContractAddress
) {
self.erc20.initializer("MyToken", "MTK");
self.erc20._mint(recipient, initial_supply);
}

#[external(v0)]
fn transfer_with_signature(
ref self: ContractState,
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64,
signature: Array<felt252>
) {
(...)
}
}
----

The `transfer_with_signature` function will allow a user to transfer tokens to another account by providing a signature.
The signature will be generated off-chain, and it will be used to verify the message on-chain. Note that the message
we need to verify is a struct with the following fields:

- `recipient`: The address of the recipient.
- `amount`: The amount of tokens to transfer.
- `nonce`: A unique number to prevent replay attacks.
- `expiry`: The timestamp when the signature expires.

Note that generating the hash of this message on-chain is a requirement to verify the signature, because if we accept
the message as a parameter, it could be easily tampered with.

== Generating the Typed Message Hash

:snip: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md#how-to-work-with-each-type[SNIP]

To generate the hash of the message, we need to follow these steps:

=== 1. Define the message struct.

In this particular example, the message struct looks like this:

[,javascript]
----
struct Message {
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64
}
----

=== 2. Get the message type hash.

This is the `starknet_keccak(encode_type(message))` as defined in the {snip}.

In this case it can be computed as follows:

[,javascript]
----
let message_type_hash = selector!(
"\"Message\"(\"recipient\":\"ContractAddress\",\"amount\":\"u256\",\"nonce\":\"felt\",\"expiry\":\"u64\")\"u256\"(\"low\":\"felt\",\"high\":\"felt\")"
);
----

which is the same as:

[,javascript]
----
let message_type_hash = 0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;
----

NOTE: In practice it's better to compute the type hash off-chain and hardcode it in the contract, since it is a constant value.

=== 3. Implement the `StructHash` trait for the struct.

You can import the trait from: `openzeppelin::utils::snip12::StructHash`. And this implementation
is nothing more than the encoding of the message as defined in the {snip}.

[,javascript]
----
use openzeppelin::utils::snip12::StructHash;

use core::hash::HashStateExTrait;
use hash::{HashStateTrait, Hash};
use poseidon::PoseidonTrait;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;

#[derive(Copy, Drop, Hash)]
struct Message {
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64
}

impl StructHashImpl of StructHash<Message> {
fn hash_struct(self: @Message) -> felt252 {
let hash_state = PoseidonTrait::new();
hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
}
}
----

=== 4. Implement the `SNIP12Metadata` trait.

This implementation determines the values of the domain separator. Only the `name` and `version` fields are required
because the `chain_id` is obtained on-chain, and the `revision` is hardcoded to `1`.

[,javascript]
----
use openzeppelin::utils::snip12::SNIP12Metadata;

impl SNIP12MetadataImpl of SNIP12Metadata {
fn name() -> felt252 { 'DAPP_NAME' }
fn version() -> felt252 { 'v1' }
}
----

NOTE: These params could be set in the contract constructor, but then two storage reads would be executed every time
a message hash needs to be generated, and this is unnecessary overhead. When Starknet implements immutable storage
set in constructor, that approach will be more efficient.

[,javascript]

=== 5. Generate the hash.

The final step is to use the `OffchainMessageHashImpl` implementation to generate the hash of the message
using the `get_message_hash` function. The implementation is already available as a utility.

[,javascript]
----
use openzeppelin::utils::snip12::{SNIP12Metadata, StructHash, OffchainMessageHashImpl};

use core::hash::HashStateExTrait;
use hash::{HashStateTrait, Hash};
use poseidon::PoseidonTrait;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;

#[derive(Copy, Drop, Hash)]
struct Message {
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64
}

impl StructHashImpl of StructHash<Message> {
fn hash_struct(self: @Message) -> felt252 {
let hash_state = PoseidonTrait::new();
hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
}
}

impl SNIP12MetadataImpl of SNIP12Metadata {
fn name() -> felt252 { 'DAPP_NAME' }
fn version() -> felt252 { 'v1' }
}

fn get_hash(
account: ContractAddress,
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64
) -> felt252 {
let message = Message {
recipient,
amount,
nonce,
expiry
};
message.get_message_hash(account)
}
----

TIP: The expected parameter for the `get_message_hash` function is the address of account that signed the message.

== Full Implementation

:dualcase_dispatchers: xref:/interfaces#dualcase_dispatchers
:nonces: xref:/utilities#NoncesComponent

Finally, the full implementation of the `CustomERC20` contract looks like this:

NOTE: We are using the {dualcase_dispatchers}[`DualCaseAccount`] to verify the signature,
and the {nonces}[`NoncesComponent`] to handle nonces to prevent replay attacks.

[,javascript]
----
use openzeppelin::utils::snip12::{SNIP12Metadata, StructHash, OffchainMessageHashImpl};

use core::hash::HashStateExTrait;
use hash::{HashStateTrait, Hash};
use poseidon::PoseidonTrait;
use starknet::ContractAddress;

const MESSAGE_TYPE_HASH: felt252 =
0x120ae1bdaf7c1e48349da94bb8dad27351ca115d6605ce345aee02d68d99ec1;

#[derive(Copy, Drop, Hash)]
struct Message {
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64
}

impl StructHashImpl of StructHash<Message> {
fn hash_struct(self: @Message) -> felt252 {
let hash_state = PoseidonTrait::new();
hash_state.update_with(MESSAGE_TYPE_HASH).update_with(*self).finalize()
}
}

#[starknet::contract]
mod CustomERC20 {
use openzeppelin::account::dual_account::{DualCaseAccount, DualCaseAccountABI};
use openzeppelin::token::erc20::ERC20Component;
use openzeppelin::utils::cryptography::nonces::NoncesComponent;
use starknet::ContractAddress;

use super::{Message, OffchainMessageHashImpl, SNIP12Metadata};

component!(path: ERC20Component, storage: erc20, event: ERC20Event);
component!(path: NoncesComponent, storage: nonces, event: NoncesEvent);

#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

#[abi(embed_v0)]
impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;
impl NoncesInternalImpl = NoncesComponent::InternalImpl<ContractState>;

#[storage]
struct Storage {
#[substorage(v0)]
erc20: ERC20Component::Storage,
#[substorage(v0)]
nonces: NoncesComponent::Storage
}

#[event]
#[derive(Drop, starknet::Event)]
enum Event {
#[flat]
ERC20Event: ERC20Component::Event,
#[flat]
NoncesEvent: NoncesComponent::Event
}

#[constructor]
fn constructor(ref self: ContractState, initial_supply: u256, recipient: ContractAddress) {
self.erc20.initializer("MyToken", "MTK");
self.erc20._mint(recipient, initial_supply);
}

/// Required for hash computation.
impl SNIP12MetadataImpl of SNIP12Metadata {
fn name() -> felt252 {
'CustomERC20'
}
fn version() -> felt252 {
'v1'
}
}

#[external(v0)]
fn transfer_with_signature(
ref self: ContractState,
recipient: ContractAddress,
amount: u256,
nonce: felt252,
expiry: u64,
signature: Array<felt252>
) {
assert(starknet::get_block_timestamp() <= expiry, 'Expired signature');
let owner = starknet::get_caller_address();

// Check and increase nonce
self.nonces.use_checked_nonce(owner, nonce);

// Build hash for calling `is_valid_signature`
let message = Message { recipient, amount, nonce, expiry };
let hash = message.get_message_hash(owner);

let is_valid_signature_felt = DualCaseAccount { contract_address: owner }
.is_valid_signature(hash, signature);

// Check either 'VALID' or True for backwards compatibility
let is_valid_signature = is_valid_signature_felt == starknet::VALIDATED
|| is_valid_signature_felt == 1;
assert(is_valid_signature, 'Invalid signature');

// Transfer tokens
self.erc20._transfer(owner, recipient, amount);
}
}
----