Skip to content

Commit e9cf166

Browse files
vobradovichclaude
andauthored
feat: Extend #[export] with per-function transport/encoding opt-in (scale and ethabi) (#1303)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cbe7090 commit e9cf166

66 files changed

Lines changed: 2609 additions & 175 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,13 +623,23 @@ The `ethexe` cargo feature enables several features:
623623
When this feature is active:
624624

625625
- Identifiers for **program constructors** and **exposed service constructors** (methods within a `#[program]` block that return a service) are validated against Solidity reserved keywords. Using a reserved name for these (e.g., `new` for a program constructor, or `function` for an exposed service constructor) will result in a compilation error, preventing naming conflicts in the generated Solidity interface. The comprehensive list of these reserved keywords can be found in the [source code](rs/macros/core/src/shared.rs) (see the `SOL_KEYWORDS` constant).
626-
- The `#[export]` macro accepts a `payable` argument (`#[export(payable)]`). This allows service methods and program constructors to accept value with a message. If a non-payable method or constructor receives value, the execution will panic.
626+
- The `#[export]` macro supports **transport selection** and `payable` methods via its arguments:
627+
- `#[export(scale)]` — expose the method only through the Gear/SCALE dispatch path.
628+
- `#[export(ethabi)]` — expose the method only through the Solidity ABI dispatch path.
629+
- `#[export(scale, ethabi)]` — expose through both paths (same as bare `#[export]`).
630+
- `#[export(payable)]` or `#[export(ethabi, payable)]` — mark the method as payable. `payable` requires `ethabi` transport; writing `#[export(scale, payable)]` is a compile error.
631+
632+
Transport flags control **runtime dispatch visibility only**. All exported methods remain in the service's IDL metadata, interface hash, and method metadata regardless of their transport selection. Single-transport methods receive a `@codec: scale` or `@codec: ethabi` annotation in the generated IDL.
633+
634+
Without the `ethexe` feature, only `#[export]` and `#[export(scale)]` are accepted; the `ethabi` and `payable` flags are unavailable.
635+
636+
Ethabi-only methods (`#[export(ethabi)]`) do not require their parameter and return types to implement SCALE `Encode`/`Decode`, allowing the use of ABI-native types such as `alloy_primitives::Address` and `alloy_primitives::B256`.
627637

628638
> **NOTE**
629639
>
630640
> The accepted value (tokens) depends on whether the `ethexe` feature is enabled. Without the feature, these are native VARA tokens; with the feature, these are ETH.
631641
632-
- The generated IDL is enhanced with structured annotations to signify payable methods, methods that return value, and indexed event fields. Specifically, methods marked with `#[export(payable)]` will have an `@payable` annotation, and methods returning `CommandReply<T>` will have a `@returns_value` annotation. Additionally, event fields marked with `#[indexed]` will have an `@indexed` annotation. This metadata is necessary for the correct generation of Solidity interfaces via the `sails-sol-gen` crate.
642+
- The generated IDL is enhanced with structured annotations to signify payable methods, methods that return value, transport-restricted methods, and indexed event fields. Specifically, methods marked with `#[export(payable)]` will have an `@payable` annotation, methods returning `CommandReply<T>` will have a `@returns_value` annotation, and single-transport methods will have a `@codec: scale` or `@codec: ethabi` annotation. Additionally, event fields marked with `#[indexed]` will have an `@indexed` annotation. This metadata is necessary for the correct generation of Solidity interfaces via the `sails-sol-gen` crate.
633643

634644
Here is an example demonstrating these features:
635645

@@ -673,16 +683,30 @@ pub struct SomeService;
673683

674684
#[service(events = MyEvent)]
675685
impl SomeService {
686+
// Available through both SCALE and Solidity ABI dispatch (default)
676687
#[export]
677688
pub async fn do_this(&mut self, p1: u32, _p2: String) -> u32 {
678689
p1
679690
}
680691

692+
// Payable method, available through both dispatch paths
681693
#[export(payable)]
682694
pub fn do_this_payable(&mut self, p1: u32) -> u32 {
683695
p1
684696
}
685697

698+
// SCALE dispatch only — not exposed to Solidity callers
699+
#[export(scale)]
700+
pub fn gear_only(&self) -> u32 {
701+
42
702+
}
703+
704+
// Solidity ABI dispatch only — not exposed to Gear/SCALE callers
705+
#[export(ethabi)]
706+
pub fn eth_only(&self) -> u32 {
707+
42
708+
}
709+
686710
// This method implicitly `returns_value` because of its return type
687711
#[export]
688712
pub fn withdraw(&mut self, amount: u64) -> CommandReply<()> {
@@ -692,6 +716,7 @@ impl SomeService {
692716
```
693717

694718
In the example above, `create_payable` is a payable constructor, and `do_this_payable` is a payable service method.
719+
The `gear_only` method is only reachable via Gear/SCALE messages, while `eth_only` is only reachable via Solidity ABI calls. Both still appear in the service's IDL and contribute to its interface hash.
695720
The `withdraw` method will have the `@returns_value` annotation in the IDL, and `from` and `to` fields in `MyEvent` will have `@indexed` annotations.
696721
For more details, you can refer to the full example at [`rs/ethexe/ethapp/src/lib.rs`](rs/ethexe/ethapp/src/lib.rs).
697722

rs/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ ethexe = [
6161
"dep:alloy-primitives",
6262
"dep:alloy-sol-types",
6363
"sails-macros/ethexe",
64+
"sails-reflect-hash/alloy-primitives",
65+
"sails-type-registry/alloy-primitives",
6466
]
6567
gclient = ["dep:gclient", "dep:tokio-stream"]
6668
gstd = ["dep:gstd", "dep:gear-core"]

rs/ethexe/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rs/ethexe/macros-tests/tests/service_insta.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,109 @@ fn works_with_basics() {
2424
insta::assert_snapshot!(result);
2525
}
2626

27+
#[test]
28+
fn works_with_explicit_scale_only() {
29+
let input = quote! {
30+
impl SomeService {
31+
#[export(scale)]
32+
pub fn scale_method(&self, p1: u32) -> u32 {
33+
p1
34+
}
35+
}
36+
};
37+
38+
let result = gservice(TokenStream::new(), input).to_string();
39+
let result = prettyplease::unparse(&syn::parse_str(&result).unwrap());
40+
41+
insta::assert_snapshot!(result);
42+
}
43+
44+
#[test]
45+
fn works_with_explicit_ethabi_only() {
46+
let input = quote! {
47+
impl SomeService {
48+
#[export(ethabi)]
49+
pub fn ethabi_method(&self, p1: u32) -> u32 {
50+
p1
51+
}
52+
}
53+
};
54+
55+
let result = gservice(TokenStream::new(), input).to_string();
56+
let result = prettyplease::unparse(&syn::parse_str(&result).unwrap());
57+
58+
insta::assert_snapshot!(result);
59+
}
60+
61+
#[test]
62+
fn works_with_explicit_dual_export() {
63+
let input = quote! {
64+
impl SomeService {
65+
#[export(scale, ethabi)]
66+
pub fn dual_method(&self, p1: u32) -> u32 {
67+
p1
68+
}
69+
}
70+
};
71+
72+
let result = gservice(TokenStream::new(), input).to_string();
73+
let result = prettyplease::unparse(&syn::parse_str(&result).unwrap());
74+
75+
insta::assert_snapshot!(result);
76+
}
77+
78+
#[test]
79+
fn works_with_mixed_transports() {
80+
let input = quote! {
81+
impl SomeService {
82+
#[export(scale)]
83+
pub fn scale_only(&self, p1: u32) -> u32 {
84+
p1
85+
}
86+
87+
#[export(ethabi)]
88+
pub fn ethabi_only(&self, p1: u32) -> u32 {
89+
p1
90+
}
91+
92+
#[export(scale, ethabi)]
93+
pub fn dual(&self, p1: u32) -> u32 {
94+
p1
95+
}
96+
97+
#[export]
98+
pub fn default_both(&self, p1: u32) -> u32 {
99+
p1
100+
}
101+
}
102+
};
103+
104+
let result = gservice(TokenStream::new(), input).to_string();
105+
let result = prettyplease::unparse(&syn::parse_str(&result).unwrap());
106+
107+
insta::assert_snapshot!(result);
108+
}
109+
110+
#[test]
111+
fn works_with_ethabi_only_non_scale_type() {
112+
let input = quote! {
113+
impl SomeService {
114+
#[export(ethabi)]
115+
pub fn abi_method(
116+
&self,
117+
addr: sails_rs::alloy_primitives::Address,
118+
) -> sails_rs::alloy_primitives::B256 {
119+
sails_rs::alloy_primitives::B256::ZERO
120+
}
121+
}
122+
};
123+
124+
let result = gservice(TokenStream::new(), input).to_string();
125+
let result = prettyplease::unparse(&syn::parse_str(&result).unwrap());
126+
127+
insta::assert_snapshot!(result);
128+
}
129+
27130
#[test]
28131
fn works_with_lifetimes_and_generics() {
29132
let input = quote! {

rs/ethexe/macros-tests/tests/service_tests.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod service_with_extends_and_lifetimes;
1515
mod service_with_lifecycles_and_generics;
1616
mod service_with_reply_with_value;
1717
mod service_with_trait_bounds;
18+
mod service_with_transport_selection;
1819

1920
#[tokio::test]
2021
async fn service_with_basics() {
@@ -324,3 +325,67 @@ async fn service_with_trait_bounds() {
324325
let result = sails_rs::alloy_sol_types::SolValue::abi_decode(output.as_slice());
325326
assert_eq!(Ok(42u32), result);
326327
}
328+
329+
#[test]
330+
fn service_transport_selection_runtime_dispatch() {
331+
use sails_rs::{
332+
Encode,
333+
alloy_sol_types::SolValue,
334+
gstd::services::Service,
335+
meta::{Identifiable, ServiceMeta, find_method_data},
336+
};
337+
use service_with_transport_selection::MyService;
338+
339+
fn ignore_result(_: &[u8], _: u128) {}
340+
341+
let methods = <MyService as ServiceMeta>::METHODS;
342+
let scale_only = find_method_data(methods, "ScaleOnly", None)
343+
.unwrap()
344+
.entry_id;
345+
let ethabi_only = find_method_data(methods, "EthabiOnly", None)
346+
.unwrap()
347+
.entry_id;
348+
let dual = find_method_data(methods, "Dual", None).unwrap().entry_id;
349+
let interface_id = <MyService as Identifiable>::INTERFACE_ID;
350+
351+
let scale_input = 7u32.encode();
352+
let abi_input = (false, 7u32).abi_encode_sequence();
353+
354+
assert!(
355+
MyService
356+
.expose(1)
357+
.try_handle(interface_id, scale_only, &scale_input, ignore_result)
358+
.is_some()
359+
);
360+
assert!(
361+
MyService
362+
.expose(1)
363+
.try_handle(interface_id, ethabi_only, &scale_input, ignore_result)
364+
.is_none()
365+
);
366+
assert!(
367+
MyService
368+
.expose(1)
369+
.try_handle(interface_id, dual, &scale_input, ignore_result)
370+
.is_some()
371+
);
372+
373+
assert!(
374+
MyService
375+
.expose(1)
376+
.try_handle_solidity(interface_id, scale_only, &abi_input)
377+
.is_none()
378+
);
379+
assert!(
380+
MyService
381+
.expose(1)
382+
.try_handle_solidity(interface_id, ethabi_only, &abi_input)
383+
.is_some()
384+
);
385+
assert!(
386+
MyService
387+
.expose(1)
388+
.try_handle_solidity(interface_id, dual, &abi_input)
389+
.is_some()
390+
);
391+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
use sails_rs::prelude::*;
2+
3+
pub struct MyService;
4+
5+
#[sails_rs::service]
6+
impl MyService {
7+
#[export(scale)]
8+
pub fn scale_only(&self, p1: u32) -> u32 {
9+
p1 + 1
10+
}
11+
12+
#[export(ethabi)]
13+
pub fn ethabi_only(&self, p1: u32) -> u32 {
14+
p1 + 2
15+
}
16+
17+
#[export(scale, ethabi)]
18+
pub fn dual(&self, p1: u32) -> u32 {
19+
p1 + 3
20+
}
21+
}

rs/ethexe/macros-tests/tests/snapshots/service_insta__works_with_allow_attrs.snap

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ impl SomeServiceExposure<SomeService> {
6262
mut input: &[u8],
6363
result_handler: fn(&[u8], u128),
6464
) -> Option<()> {
65-
use sails_rs::gstd::{InvocationIo, CommandReply};
65+
use sails_rs::gstd::CommandReply;
6666
match (interface_id, entry_id) {
6767
(
6868
id,
@@ -76,7 +76,11 @@ impl SomeServiceExposure<SomeService> {
7676
let result = self.this(request.p1);
7777
let value = 0u128;
7878
if !sails_rs::gstd::is_empty_tuple::<bool>() {
79-
<some_service_meta::__ThisParams as sails_rs::gstd::InvocationIo>::with_optimized_encode(
79+
sails_rs::gstd::encode_invocation_payload::<
80+
some_service_meta::__ThisParams,
81+
_,
82+
_,
83+
>(
8084
&result,
8185
self.route_idx,
8286
|encoded_result| result_handler(encoded_result, value),
@@ -96,7 +100,7 @@ impl SomeServiceExposure<SomeService> {
96100
mut input: &[u8],
97101
result_handler: fn(&[u8], u128),
98102
) -> Option<()> {
99-
use sails_rs::gstd::{InvocationIo, CommandReply};
103+
use sails_rs::gstd::CommandReply;
100104
match (interface_id, entry_id) {
101105
(
102106
id,
@@ -110,7 +114,11 @@ impl SomeServiceExposure<SomeService> {
110114
let result = self.do_this(request.p1, request.p2).await;
111115
let value = 0u128;
112116
if !sails_rs::gstd::is_empty_tuple::<u32>() {
113-
<some_service_meta::__DoThisParams as sails_rs::gstd::InvocationIo>::with_optimized_encode(
117+
sails_rs::gstd::encode_invocation_payload::<
118+
some_service_meta::__DoThisParams,
119+
_,
120+
_,
121+
>(
114122
&result,
115123
self.route_idx,
116124
|encoded_result| result_handler(encoded_result, value),

0 commit comments

Comments
 (0)