Skip to content

Commit

Permalink
feat: add cli and separate rs and rs-macro
Browse files Browse the repository at this point in the history
  • Loading branch information
glihm committed Jan 15, 2024
1 parent 167c01e commit dc0a332
Show file tree
Hide file tree
Showing 20 changed files with 1,174 additions and 284 deletions.
429 changes: 413 additions & 16 deletions Cargo.lock

Large diffs are not rendered by default.

35 changes: 27 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,50 @@ members = [
"crates/cairo-serde",
"crates/parser",
"crates/rs",
"crates/rs-macro",
]

[workspace.dependencies]
# workspace crates
cainome-cairo-serde = { path = "crates/cairo-serde" }
cainome-parser = { path = "crates/parser" }
cainome-rs = { path = "crates/rs" }

# serde
anyhow = "1.0"
async-trait = "0.1.68"
serde = { version = "1.0", default-features = false, features = ["alloc"] }
serde_json = { version = "1.0", default-features = false, features = ["std"] }
thiserror = "1.0"
anyhow = "1.0"

starknet = "0.8.0"
thiserror = "1.0"
tracing = "0.1.34"
tracing-subscriber = { version = "0.3.16", features = [ "env-filter", "json" ] }
url = "2.4.0"

[dependencies]
cainome-parser.workspace = true
cainome-cairo-serde.workspace = true
cainome-rs = { path = "crates/rs", optional = true }
cainome-rs.workspace = true
cainome-rs-macro = { path = "crates/rs-macro", optional = true }

[dev-dependencies]
async-trait.workspace = true
anyhow.workspace = true
camino = { version = "1.1.2", features = [ "serde1" ] }
clap = { version = "4.2", features = [ "derive" ] }
clap_complete = "4.3"
convert_case = "0.6.0"
serde.workspace = true
serde_json.workspace = true
starknet.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
tokio = { version = "1.15.0", features = ["full"] }
url = "2.2.2"

[features]
default = []
abigen-rs = ["cainome-rs"]
abigen-rs = ["cainome-rs-macro"]

[[bin]]
name = "cainome"
path = "src/bin/cli/main.rs"
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,35 @@ for different languages (backends).

## Project structure

- **cli**: inside `src/bin/cli`, the cainome CLI binary can be built using `cargo build`: [README](./src/bin/cli/README.md).
- **lib**: inside `src/lib.rs`, the cainome library can be built using `cargo build --lib`.
- **parser**: a run-time library to parse an ABI file into `Token`s [README](./crates/parser/README.md).
- **cairo-serde**: a compile-time library that implements serialization for native Rust types from `FieldElement` buffer [README](./crates/cairo-serde/README.md).
- **rs**: a compile-time library backend for the `abigen` macro to generate rust bindings [README](./crates/rs/README.md).
- **rs-macro**: a compile-time library backend for the `abigen` macro to generate rust bindings [README](./crates/rs-macro/README.md).
- **rs**: a a run-time library to generated rust bindings [README](./crates/rs/README.md).
- **ts**: a compile-time library backend to generate `TypeScript` bindings (coming soon).

Currently those crates are not published on crates.io, please consider using them with the release tags.

## Plugin system

Cainome uses a plugin system that is for now only supporting `built-in` plugins (written in rust).
Cainome will support in the future plugins like `protobuf`, which can be written in any languages.

### How to write a plugin

Currently, to write a plugin you can take as example the `RustPlugin`.

1. Define a rust module inside `src/bin/cli/plugins/builtins`.
2. You can write your plugin code in a crate (like `rs` crate), or in the module you've created at the previous step (use a folder in this case).
Writting a crate can be easier to re-use in other projects though.
3. The plugin takes a `PluginInput` as argument, where the [tokens from the parser crate](./crates/parser/src/tokens/mod.rs) are available for each contract.
From these tokens, you can easily generate code that represent the ABI of the contract. In the case of rust, you can find in the `rs` crate
some examples of how types are handled.
You don't have to use `syn` crate as `rs` crate is doing. You can simply build strings.
4. In the current version, the plugin also receives the `output_dir`, so it is responsible of writing and organizing it's files.
5. Finally, add in the [PluginOptions](./src/bin/cli/args.rs) an option for your plugin.

## Cainome meaning

Cainome is a word combining `Cairo` and `Genome`. The idea of `Cairo ABI` being the DNA of our ecosystem,
Expand Down
19 changes: 19 additions & 0 deletions crates/rs-macro/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "cainome-rs-macro"
version = "0.1.0"
edition = "2021"

[lib]
proc-macro = true

[dependencies]
anyhow.workspace = true
starknet.workspace = true
cainome-parser.workspace = true
cainome-rs.workspace = true
proc-macro2 = "1.0"
quote = "1.0"
syn = "2.0.15"
serde_json = "1.0.74"
thiserror.workspace = true
cainome-cairo-serde.workspace = true
224 changes: 224 additions & 0 deletions crates/rs-macro/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Cainome Rust abigen

This crates contains the compile-time rust macro `abigen` to generate rust bindings (using Cairo Serde).

## Import in your project

```toml
# Cargo.toml

cainome = { git = "https://github.com/cartridge-gg/cainome", tag = "v0.1.10", features = ["abigen-rs"] }
```

```rust
// Rust code
use cainome::rs::abigen;

abigen!(MyContract, "/path/my_contract.json");
```

## Usage

For examples, please refer to the [examples](../../examples) folder.

The `abigen!` macro takes 2 or 3 inputs:

1. The name you want to assign to the contract type being generated.
2. Path to the JSON file containing the ABI. This file can have two format:

- The entire Sierra file (`*.contract_class.json`)
- Only the array of ABI entries. These can be easily extracted with `jq` doing the following:

```
jq .abi ./target/dev/package_contract.contract_class.json > /path/contract.json
```

3. Optional parameters:
- `output_path`: if provided, the content will be generated in the given file instead of being expanded at the location of the macro invocation.
- `type_aliases`: to avoid type name conflicts between components / contracts, you can rename some type by providing an alias for the full type path. It is important to give the **full** type path to ensure aliases are applied correctly.

```rust
use cainome::rs::abigen;

// Default.
abigen!(MyContract, "/path/contract.json");

// Example with optional output path:
abigen!(MyContract, "/path/contract.json", output_path("/path/module.rs"));

// Example type aliases:
abigen!(
MyContract,
"./contracts/abi/components.abi.json",
type_aliases {
package::module1::component1::MyStruct as MyStruct1;
package::module2::component2::MyStruct as MyStruct2;
},
);

fn main() {
// ... use the generated types here, which all of them
// implement CairoSerde trait.
}
```

As a known limitation of `Cargo`, the `/path/contract.json` is relative to the Cargo manifest (`Cargo.toml`). This is important when executing a specific package (`-p`) or from the workspace (`--workspace/--all`), the manifest directory is not the same!

## What is generated

The expansion of the macros generates the following:

- For every type that is exposed in the ABI, a `struct` or `enum` will be generated with the `CairoSerde` trait automatically derived. The name of the type if always the last segment of the full type path, enforced to be in `PascalCase`.

```rust
// Take this cairo struct, in with the full path `package::my_contract::MyStruct
MyStruct {
a: felt252,
b: u256,
}

// This will generate a rust struct with the make `MyStruct`:
MyStruct {
a: starknet::core::types::FieldElement,
a: U256, // Note the `PascalCase` here. As `u256` is a struct, it follows the common rule.
}
```

- **Contract** type with the identifier of your choice (`MyContract` in the previous example). This type contains all the functions (externals and views) of your contract being exposed in the ABI. To initialize this type, you need the contract address and any type that implements `ConnectedAccount` from `starknet-rs`. Remember that `Arc<ConnectedAccount>` also implements `ConnectedAccount`.
```rust
let account = SingleOwnerAccount::new(...);
let contract_address = FieldElement::from_hex_be("0x1234...");
let contract = MyContract::new(contract_address, account);
```
- **Contract Reader** type with the identifier of your choice with the suffix `Reader` (`MyContractReader`) in the previous example. The reader contains only the views of your contract. To initialize a reader, you need the contract address and a provider from `starknet-rs`.
```rust
let provider = AnyProvider::JsonRpcHttp(...);
let contract_address = FieldElement::from_hex_be("0x1234...");
let contract_reader = MyContractReader::new(contract_address, &provider);
```
- For each **view**, the contract type and the contract reader type contain a function with the exact same arguments. Calling the function returns a `cainome_cairo_serde::call::FCall` struct to allow you to customize how you want the function to be called. Currently, the only setting is the `block_id`. Finally, to actually do the RPC call, you have to use `call()` method on the `FCall` struct.
The default `block_id` value is `BlockTag::Pending`.
```rust
let my_struct = contract
.get_my_struct()
.block_id(BlockId::Tag(BlockTag::Latest))
.call()
.await
.expect("Call to `get_my_struct` failed");
```
- For each **external**, the contract type contains a function with the same arguments. Calling the function return a `starknet::accounts::Execution` type from `starknet-rs`, which allows you to completly customize the fees, doing only a simulation etc... To actually send the transaction, you use the `send()` method on the `Execution` struct. You can find the [associated methods with this struct on starknet-rs repo](https://github.com/xJonathanLEI/starknet-rs/blob/0df9ad3417a5f10d486348737fe75659ca4bcfdc/starknet-accounts/src/account/execution.rs#L118).

```rust
let my_struct = MyStruct {
a: FieldElement::ONE,
b: U256 {
low: 1,
high: 0,
}
};

let tx_res = contract
.set_my_struct(&my_struct)
.max_fee(1000000000000000_u128.into())
.send()
.await
.expect("Call to `set_my_struct` failed");
```

To support multicall, currently `Execution` type does not expose the `Call`s.
To circumvey this, for each of the external function an other function with `_getcall` suffix is generated:

```rust
// Gather the `Call`s.
let set_a_call = contract.set_a_getcall(&FieldElement::ONE);
let set_b_call = contract.set_b_getcall(&U256 { low: 0xff, high: 0 });

// Then use the account exposed by the `MyContract` type to realize the multicall.
let tx_res = contract
.account
.execute(vec![set_a_call, set_b_call])
.send()
.await
.expect("Multicall failed");
```

- For each `Event` enumeration in the contract, the trait `TryFrom<EmittedEvent>` is generated. `EmittedEvent` is the type used
by `starknet-rs` when events are fetched using `provider.get_events()`.

```rust
let events = provider.get_events(...).await.unwrap();

for event in events {
match event.try_into() {
Ok(ev) => {
// Here, `ev` is deserialized + selectors are checked.
}
Err(e) => {
trace!("Event can't be deserialized to any known Event variant: {e}");
continue;
}
};
```

## Known limitation

With the current state of the parser, here are some limitations:

1. Generic arguments: even if the library currently supports generic arguments, sometimes the simple algorithm for generic resolution is not able to re-construct the expected generic mapping. This may cause compilation errors. Take an example with:

```rust
struct GenericTwo<A, B> {
a: A,
b: B,
c: felt252,
}
```

If the cairo code only have one use of this struct like this:

```rust
fn my_func(self: @ContractState) -> GenericTwo<u64, u64>;
```

Then the ABI will look like this:

```json
{
"type": "struct",
"name": "contracts::abicov::structs::GenericTwo::<core::integer::u64, core::integer::u64>",
"members": [
{
"name": "a",
"type": "core::integer::u64"
},
{
"name": "b",
"type": "core::integer::u64"
},
{
"name": "c",
"type": "core::felt252"
}
]
},
```

And here... how can we know that `a` is `A` and `b` is `B`? The current algorithm will generate the following:

```rust
struct GenericTwo<A, B> {
a: A,
b: A,
c: felt252,
}
```

Which will cause a compilation error.

A first approach to this, is to add a `Phantom` placeholder for each of the variant. To ensure that there is always the two generic args used. But this will prevent the easy initialization of the struct with the fields. Need to check if we can use `Default`, or instead, using a `new(..)` pattern.

## Roadmap

1. [ ] Add a simple transaction status watcher integrated to the contract type.
2. [ ] Add declare and deploy function to the contract type.
3. [ ] Custom choice of derive for generated structs/enums.
38 changes: 38 additions & 0 deletions crates/rs-macro/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use cainome_parser::AbiParser;
use cainome_rs::{self};
use proc_macro::TokenStream;
use quote::quote;

mod macro_inputs;
mod spanned;

use crate::macro_inputs::ContractAbi;

#[proc_macro]
pub fn abigen(input: TokenStream) -> TokenStream {
abigen_internal(input)
}

fn abigen_internal(input: TokenStream) -> TokenStream {
let contract_abi = syn::parse_macro_input!(input as ContractAbi);

let contract_name = contract_abi.name;
let abi_entries = contract_abi.abi;

let abi_tokens = AbiParser::collect_tokens(&abi_entries).expect("failed tokens parsing");
let abi_tokens = AbiParser::organize_tokens(abi_tokens, &contract_abi.type_aliases);

let expanded = cainome_rs::abi_to_tokenstream(&contract_name.to_string(), &abi_tokens);

if let Some(out_path) = contract_abi.output_path {
let content: String = expanded.to_string();
match std::fs::write(out_path, content) {
Ok(_) => (),
Err(e) => panic!("Failed to write to file: {}", e),
}

quote!().into()
} else {
expanded.into()
}
}
Loading

0 comments on commit dc0a332

Please sign in to comment.