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

Add erc20-contract template #3110

Merged
merged 14 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### Added

- `--template` flag to `snforge new` command that allows selecting a template for the new project. Possible values are `balance-contract` (default) and `cairo-program`
- `--template` flag to `snforge new` command that allows selecting a template for the new project. Possible values are `balance-contract` (default), `cairo-program` and `erc20-contract`

## [0.40.0] - 2025-03-26

Expand Down
3 changes: 3 additions & 0 deletions crates/forge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ pub enum Template {
/// Basic contract with example tests
#[display("balance-contract")]
BalanceContract,
/// ERC20 contract for mock token
#[display("erc20-contract")]
Erc20Contract,
}

#[derive(Parser, Debug)]
Expand Down
65 changes: 65 additions & 0 deletions crates/forge/src/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ impl Dependency {
struct TemplateManifestConfig {
dependencies: Vec<Dependency>,
contract_target: bool,
fork_config: bool,
}

impl TemplateManifestConfig {
Expand Down Expand Up @@ -80,6 +81,10 @@ impl TemplateManifestConfig {
add_assert_macros(&mut document)?;
add_allow_prebuilt_macros(&mut document)?;

if self.fork_config {
add_fork_config(&mut document)?;
}

fs::write(scarb_manifest_path, document.to_string())?;

Ok(())
Expand All @@ -95,6 +100,7 @@ impl TryFrom<&Template> for TemplateManifestConfig {
Template::CairoProgram => Ok(TemplateManifestConfig {
dependencies: vec![],
contract_target: false,
fork_config: false,
}),
Template::BalanceContract => Ok(TemplateManifestConfig {
dependencies: vec![Dependency {
Expand All @@ -103,6 +109,23 @@ impl TryFrom<&Template> for TemplateManifestConfig {
dev: false,
}],
contract_target: true,
fork_config: false,
}),
Template::Erc20Contract => Ok(TemplateManifestConfig {
dependencies: vec![
Dependency {
name: "starknet".to_string(),
version: cairo_version.to_string(),
dev: false,
},
Dependency {
name: "openzeppelin_token".to_string(),
version: get_oz_version()?.to_string(),
dev: false,
},
],
contract_target: true,
fork_config: true,
}),
}
}
Expand Down Expand Up @@ -240,6 +263,34 @@ fn add_allow_prebuilt_macros(document: &mut DocumentMut) -> Result<()> {
Ok(())
}

fn add_fork_config(document: &mut DocumentMut) -> Result<()> {
let tool_section = document.entry("tool").or_insert(Item::Table(Table::new()));
let tool_table = tool_section
.as_table_mut()
.context("Failed to get tool table from Scarb.toml")?;

let mut fork_table = Table::new();
fork_table.insert("name", Item::Value(Value::from("SEPOLIA_LATEST")));
fork_table.insert("url", Item::Value(Value::from(FREE_RPC_PROVIDER_URL)));

let mut block_id_table = Table::new();
block_id_table.insert("tag", Item::Value(Value::from("latest")));
fork_table.insert(
"block_id",
Item::Value(Value::from(block_id_table.into_inline_table())),
);

let mut array_of_tables = ArrayOfTables::new();
array_of_tables.push(fork_table);

let mut fork = Table::new();
fork.set_implicit(true);
fork.insert("fork", Item::ArrayOfTables(array_of_tables));
tool_table.insert("snforge", Item::Table(fork));

Ok(())
}

fn extend_gitignore(path: &Path) -> Result<()> {
if path.join(".gitignore").exists() {
let mut file = OpenOptions::new()
Expand Down Expand Up @@ -345,10 +396,24 @@ fn get_template_dir(template: &Template) -> Result<Dir> {
let dir_name = match template {
Template::CairoProgram => "cairo_program",
Template::BalanceContract => "balance_contract",
Template::Erc20Contract => "erc20_contract",
};

TEMPLATES_DIR
.get_dir(dir_name)
.ok_or_else(|| anyhow!("Directory {dir_name} not found"))
.cloned()
}

fn get_oz_version() -> Result<Version> {
let scarb_version = ScarbCommand::version().run()?.scarb;

let oz_version = match scarb_version {
ver if ver >= Version::new(2, 9, 4) => Version::new(1, 0, 0),
ver if ver >= Version::new(2, 9, 1) => Version::new(0, 20, 0),
ver if ver >= Version::new(2, 8, 4) => Version::new(0, 19, 0),
_ => bail!("Minimal Scarb version to create a new project with ERC-20 template is 2.8.4"),
};

Ok(oz_version)
}
69 changes: 62 additions & 7 deletions crates/forge/tests/e2e/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@ use forge::CAIRO_EDITION;
use forge::Template;
use forge::scarb::config::SCARB_MANIFEST_TEMPLATE_CONTENT;
use indoc::{formatdoc, indoc};
use regex::Regex;
use shared::consts::FREE_RPC_PROVIDER_URL;
use shared::test_utils::output_assert::assert_stdout_contains;
use snapbox::assert_matches;
use snapbox::cmd::Command as SnapboxCommand;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock;
use std::{env, fs, iter};
use test_case::test_case;
use test_utils::{get_local_snforge_std_absolute_path, tempdir_with_tool_versions};
use toml_edit::{DocumentMut, Formatted, InlineTable, Item, Value};

static RE_NEWLINES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\n{3,}").unwrap());

#[test]
fn init_new_project() {
let temp = tempdir_with_tool_versions().unwrap();
Expand All @@ -43,6 +48,7 @@ fn init_new_project() {

#[test_case(&Template::CairoProgram; "cairo-program")]
#[test_case(&Template::BalanceContract; "balance-contract")]
#[test_case(&Template::Erc20Contract; "erc20-contract")]
fn create_new_project_dir_not_exist(template: &Template) {
let temp = tempdir_with_tool_versions().unwrap();
let project_path = temp.join("new").join("project");
Expand Down Expand Up @@ -172,15 +178,31 @@ fn get_expected_manifest_content(template: &Template, validate_snforge_std: bool
""
};

let target_contract_entry = "[[target.starknet-contract]]\nsierra = true";

let fork_config = if let Template::Erc20Contract = template {
&formatdoc!(
r#"
[[tool.snforge.fork]]
name = "SEPOLIA_LATEST"
url = "{FREE_RPC_PROVIDER_URL}"
block_id = {{ tag = "latest" }}
"#
)
} else {
""
};

let (dependencies, target_contract_entry) = match template {
Template::BalanceContract => (
"starknet = \"[..]\"\n",
"\n[[target.starknet-contract]]\nsierra = true\n",
Template::BalanceContract => ("starknet = \"[..]\"", target_contract_entry),
Template::Erc20Contract => (
"openzeppelin_token = \"[..]\"\nstarknet = \"[..]\"",
target_contract_entry,
),
Template::CairoProgram => ("", ""),
};

formatdoc!(
let expected_manifest = formatdoc!(
r#"
[package]
name = "test_name"
Expand All @@ -191,18 +213,30 @@ fn get_expected_manifest_content(template: &Template, validate_snforge_std: bool

[dependencies]
{dependencies}

[dev-dependencies]{}
assert_macros = "[..]"

{target_contract_entry}

[scripts]
test = "snforge test"

[tool.scarb]
allow-prebuilt-plugins = ["snforge_std"]
{SCARB_MANIFEST_TEMPLATE_CONTENT}

{fork_config}

{}
"#,
snforge_std_assert
).trim_end().to_string() + "\n"
snforge_std_assert,
SCARB_MANIFEST_TEMPLATE_CONTENT.trim_end()
);

// Replace 3 or more consecutive newlines with exactly 2 newlines
RE_NEWLINES
.replace_all(&expected_manifest, "\n\n")
.to_string()
}

fn get_expected_output(template: &Template) -> &str {
Expand Down Expand Up @@ -235,6 +269,27 @@ fn get_expected_output(template: &Template) -> &str {
"
)
}
Template::Erc20Contract => {
indoc!(
r"
[..]Compiling[..]
[..]Finished[..]

Collected 8 test(s) from test_name package
Running 8 test(s) from tests/
[PASS] test_name_integrationtest::test_erc20::should_panic_transfer [..]
[PASS] test_name_integrationtest::test_erc20::test_get_balance [..]
[PASS] test_name_integrationtest::test_erc20::test_transfer [..]
[PASS] test_name_integrationtest::test_erc20::test_transfer_event [..]
[PASS] test_name_integrationtest::test_token_sender::test_multisend [..]
[PASS] test_name_integrationtest::test_token_sender::test_single_send_fuzz [..]
[PASS] test_name_integrationtest::test_token_sender::test_single_send [..]
[PASS] test_name_integrationtest::test_erc20::test_fork_transfer [..]
Running 0 test(s) from src/
Tests: 8 passed, 0 failed, 0 skipped, 0 ignored, 0 filtered out
"
)
}
}
}

Expand Down
1 change: 1 addition & 0 deletions docs/src/appendix/snforge/new.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Name of a package to create, defaults to the directory name.
Name of a template to use when creating a new project. Possible values:
- `balance-contract` (default): Basic contract with example tests.
- `cairo-program`: Simple Cairo program with unit tests.
- `erc20-template`: Includes an ERC-20 token contract and a contract that allows multiple transfers of a specific token.

## `--no-vcs`

Expand Down
2 changes: 2 additions & 0 deletions snforge_templates/erc20_contract/src/lib.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod mock_erc20;
pub mod token_sender;
43 changes: 43 additions & 0 deletions snforge_templates/erc20_contract/src/mock_erc20.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/// Example ERC20 token contract created with openzeppelin dependency.
/// Full guide and documentation can be found at:
/// https://docs.openzeppelin.com/contracts-cairo/1.0.0/guides/erc20-supply
#[starknet::contract]
pub mod MockERC20 {
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
use starknet::ContractAddress;

/// Declare the ERC20 component for this contract.
/// This allows the contract to inherit ERC20 functionalities.
component!(path: ERC20Component, storage: erc20, event: ERC20Event);

/// Define ERC20 public interface.
#[abi(embed_v0)]
impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;

/// Define internal implementation, allowing internal modifications like minting.
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

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

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

#[constructor]
fn constructor(ref self: ContractState, initial_supply: u256, recipient: ContractAddress) {
let name = "MockToken";
let symbol = "MTK";

/// Initialize the contract by setting the token name and symbol.
self.erc20.initializer(name, symbol);
/// Create `initial_supply` amount of tokens and assigns them to `recipient`.
self.erc20.mint(recipient, initial_supply);
}
}
83 changes: 83 additions & 0 deletions snforge_templates/erc20_contract/src/token_sender.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use starknet::ContractAddress;

#[derive(Drop, Serde, Copy)]
pub struct TransferRequest {
pub recipient: ContractAddress,
pub amount: u256,
}

/// Interface representing `TokenSender` contract functionality.
#[starknet::interface]
pub trait ITokenSender<TContractState> {
/// Function to send tokens to multiple recipients in a single transaction.
/// - `token_address` - The address of the token contract
/// - `transfer_list` - The list of transfers to perform
fn multisend(
ref self: TContractState,
token_address: ContractAddress,
transfer_list: Array<TransferRequest>,
) -> ();
}

#[starknet::contract]
pub mod TokenSender {
use openzeppelin_token::erc20::{ERC20ABIDispatcher, ERC20ABIDispatcherTrait};
use starknet::{ContractAddress, get_caller_address, get_contract_address};
use super::TransferRequest;


#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
TransferSent: TransferSent,
}

#[derive(Drop, starknet::Event)]
pub struct TransferSent {
#[key]
pub recipient: ContractAddress,
pub token_address: ContractAddress,
pub amount: u256,
}


#[constructor]
fn constructor(ref self: ContractState) {}

#[storage]
struct Storage {}

#[abi(embed_v0)]
impl TokenSender of super::ITokenSender<ContractState> {
fn multisend(
ref self: ContractState,
token_address: ContractAddress,
transfer_list: Array<TransferRequest>,
) {
// Create an ERC20 dispatcher to interact with the given token contract.
let erc20 = ERC20ABIDispatcher { contract_address: token_address };

// Compute total amount to be transferred.
let mut total_amount: u256 = 0;
for t in transfer_list.span() {
total_amount += *t.amount;
};

// Transfer the total amount from the caller to this contract.
erc20.transfer_from(get_caller_address(), get_contract_address(), total_amount);

// Distribute tokens to each recipient in the transfer list.
for t in transfer_list.span() {
erc20.transfer(*t.recipient, *t.amount);
self
.emit(
TransferSent {
recipient: *t.recipient,
token_address: token_address,
amount: *t.amount,
},
);
};
}
}
}
Loading
Loading