Skip to content

Commit

Permalink
SNIP 12 Utilities (#935)
Browse files Browse the repository at this point in the history
* feat: add main logic

* feat: docs

* feat: add Nonces and tests

* docs: finish v1

* feat: update CHANGELOG

* fix: format

* Update docs/modules/ROOT/pages/guides/snip12.adoc

Co-authored-by: Andrew Fleming <[email protected]>

* Update docs/modules/ROOT/pages/guides/snip12.adoc

Co-authored-by: Andrew Fleming <[email protected]>

* Update docs/modules/ROOT/pages/guides/snip12.adoc

Co-authored-by: Andrew Fleming <[email protected]>

* Update docs/modules/ROOT/pages/guides/snip12.adoc

Co-authored-by: Andrew Fleming <[email protected]>

* Update docs/modules/ROOT/pages/guides/snip12.adoc

Co-authored-by: Andrew Fleming <[email protected]>

* feat: apply update reviews

* fix: tests

* fix: linter

* Update docs/modules/ROOT/pages/guides/snip12.adoc

Co-authored-by: Andrew Fleming <[email protected]>

* feat: apply review updates

---------

Co-authored-by: Andrew Fleming <[email protected]>
  • Loading branch information
ericnordelo and andrew-fleming authored Mar 29, 2024
1 parent c3894a9 commit b5413be
Show file tree
Hide file tree
Showing 15 changed files with 743 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- SNIP12 utilities for on-chain typed messages hash generation (#935)
- Nonces component utility (#935)
- Presets Usage guide (#949)
- UDC preset contract (#919)
- ERC1155Component and ERC1155ReceiverComponent mixins (#941)
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
using these utilties for on-chain signature verification. For that, let's build an example with a custom {erc20} contract
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);
}
}
----
Loading

0 comments on commit b5413be

Please sign in to comment.