DEV Community

Cover image for Implementing a proxy contract for the game_engine to log solution-steps in fca00c
Paul
Paul

Posted on

Implementing a proxy contract for the game_engine to log solution-steps in fca00c

To provide a hands on gamified approach to tinker with soroban, the latest series of Stellar Quest (fca00c: Asteroids) was all about submitting a compiled WASM to compete in three categories:

  1. earliest working submission
  2. smallest working submission
  3. most efficient (CPU cycles) working submission

Optimizing the contract

Although soroban already offers pretty resource-oriented logging capabilites (e.g. strips everything related to logging from release builds), all the logic around making logs readable would still stay in the contract and bloat the file size.

So our approach here is to keep everything log-related out of the solution (that was supposed to be submitted) and use a proxy between the solution and the game-engine provided. To achieve that we can make use of a proxy that logs all the commands before invoking them on the actual engine.

A few things need to be considered here:

  • release builds (as described/configured per soroban docs) strip the log! macro related code
  • we want the proxy contract to always log - even if used by a release solution
  • as the proxy is another soroban contract, the same limitations regarding logging/formatting apply due to std not being available

For sake of context let's just add the contract next to the solution contract but keep it out of the workspace:

❯ tree contracts/ -L 2
contracts/
β”œβ”€β”€ _game_engine
β”‚   β”œβ”€β”€ engine.rs
β”‚   β”œβ”€β”€ lib.rs
β”‚   β”œβ”€β”€ map.rs
β”‚   β”œβ”€β”€ storage.rs
β”‚   └── types.rs
β”œβ”€β”€ game_engine.wasm
β”œβ”€β”€ logging_engine
β”‚   β”œβ”€β”€ Cargo.lock
β”‚   β”œβ”€β”€ Cargo.toml
β”‚   └── src
└── solution
    β”œβ”€β”€ Cargo.toml
    └── src

5 directories, 9 files
❯ 
Enter fullscreen mode Exit fullscreen mode

That way we can individually configure the contract to always build with debug-assertions = true but we don't need to deal with configuring this as a workspace package with profile-overrides.

Implementing the engine contract

Open in GitHub Codespaces
First of all the original game_engine contract-interface needs to be implemented so that our logging_engine provides all the expected functions defined by the game_engine::Contract.

By using impl Contract for LoggingEngine the complete game_engine interface is enforced to be implemented.

contracts/logging_engine/src/engine.rs:

show file listing
use soroban_sdk::{contractimpl, Env, Map};
mod game_engine {
    soroban_sdk::contractimport!(file = "../game_engine.wasm");
}
use game_engine::Contract;

pub struct LoggingEngine;
#[contractimpl]
impl Contract for LoggingEngine {
    fn init(
        env: Env,
        move_step: u32,
        laser_range: u32,
        seed: u64,
        view_range: u32,
        fuel: (u32, u32, u32, u32),
        asteroid_reward: u32,
        asteroid_density: u32,
        pod_density: u32,
    ) {
        todo!("needs implementation")
    }
    fn p_turn(env: Env, direction: game_engine::Direction) -> Result<(), game_engine::Error> {
        todo!("needs implementation")
    }
    fn p_move(env: Env, times: Option<u32>) -> Result<(), game_engine::Error> {
        todo!("needs implementation")
    }
    fn p_shoot(env: Env) -> Result<(), game_engine::Error> {
        todo!("needs implementation")
    }
    fn p_harvest(env: Env) -> Result<(), game_engine::Error> {
        todo!("needs implementation")
    }
    fn p_upgrade(env: Env) -> Result<(), game_engine::Error> {
        todo!("needs implementation")
    }
    fn p_pos(env: Env) -> game_engine::Point {
        todo!("needs implementation")
    }
    fn p_dir(env: Env) -> game_engine::Direction {
        todo!("needs implementation")
    }
    fn p_points(env: Env) -> u32 {
        todo!("needs implementation")
    }
    fn p_fuel(env: Env) -> u32 {
        todo!("needs implementation")
    }
    fn get_map(env: Env) -> Map<game_engine::Point, game_engine::MapElement> {
        todo!("needs implementation")
    }
}
Enter fullscreen mode Exit fullscreen mode

This can only be an intermediary step to make sure every function was implemented. Unfortunately only a single #[contractimpl] is possible per soroban contract.

As we want to augment the logging contract with at least one more function (we need to tell the proxy what to actually proxy, right?) we need to remove to impl Contract for because we can't add functions to an implementation that are not defined in the trait.

So let's compile the contract to make sure everything is in place:

❯ RUSTFLAGS="-A unused" \
  cargo build \
  --target wasm32-unknown-unknown \
  --release 
    Finished release [unoptimized + debuginfo] target(s) in 0.12s

❯
Enter fullscreen mode Exit fullscreen mode

we are passing the RUSTFLAGS here once just to prevent ⚠️warnings⚠️ for all the unused variables

πŸŽ‰ Great - it builds πŸ› οΈ

Proxying the game_engine::Client

Now let's add the actual proxying to the implementation.

First we need a way to call the proxied client. So let's add another function (wrap) that accepts the client-id.

@@ -1,13 +1,27 @@
-use soroban_sdk::{contractimpl, Env, Map};
+use soroban_sdk::{contractimpl, log, BytesN, Env, Map};
 mod game_engine {
     soroban_sdk::contractimport!(file = "../game_engine.wasm");
 }
-use game_engine::Contract;
+
+const ENGINE_ID: &str = "engine";

 pub struct LoggingEngine;
 #[contractimpl]
-impl Contract for LoggingEngine {
-    fn init(
+impl LoggingEngine {
+    pub fn wrap(env: Env, engine_id: BytesN<32>) {
+        env.storage().set(&ENGINE_ID, &engine_id);
+        log!(&env, "πŸ—’οΈ logger engine taking notes");
+    }
+
+    fn engine_id(env: Env) -> BytesN<32> {
+        env.storage().get(&ENGINE_ID).unwrap().unwrap()
+    }
+    fn get_engine(env: &Env) -> game_engine::Client {
+        game_engine::Client::new(&env, &Self::engine_id(env.clone()))
+    }
+
+    /// wrapping interface implemention
+    pub fn init(
         env: Env,
         move_step: u32,
         laser_range: u32,
Enter fullscreen mode Exit fullscreen mode

Additionally we'd add all the proxy-calls (see here for the full diff) and build again:

❯ cargo build --target wasm32-unknown-unknown --release 
   Compiling stellar-xdr v0.0.14
   Compiling static_assertions v1.1.0
   Compiling soroban-env-common v0.0.14
   Compiling soroban-env-guest v0.0.14
   Compiling soroban-sdk v0.6.0
   Compiling logging-engine v0.0.0 (/home/paul/Code/fca00c - logging proxy/contracts/logging_engine)
    Finished release [unoptimized + debuginfo] target(s) in 7.91s
❯

Enter fullscreen mode Exit fullscreen mode

🎊 no ⚠️warning⚠️ anymore

🧩 Now let's put this thing together

Finally we want to see if/how this can be invoked in our test.

It's pretty easy: As both contracts implement the same interface we can even use the original game_engine::Client to invoke functions in the proxy (logging_engine).

To prove this is working let's just shoot the first asteroid (which is fortunately just ahead of the starting position):

--- a/contracts/solution/src/lib.rs
+++ b/contracts/solution/src/lib.rs
@@ -18,6 +18,7 @@ impl Solution {

         // YOUR CODE START

+        engine.p_shoot();
         // YOUR CODE END
     }
 }
Enter fullscreen mode Exit fullscreen mode

and do this through the logging_engine:

--- a/contracts/solution/src/test.rs
+++ b/contracts/solution/src/test.rs
@@ -15,7 +15,7 @@
 /// cost will decrease as well.
 use std::println;

-use soroban_sdk::Env;
+use soroban_sdk::{testutils::Logger, Env};

 use crate::{
     engine::{Client as GameEngine, WASM as GameEngineWASM},
@@ -24,14 +24,25 @@ use crate::{

 extern crate std;

+mod logging_contract {
+    use crate::engine::{Direction, Error, MapElement, Point};
+
+    soroban_sdk::contractimport!(
+        file = "../logging_engine/target/wasm32-unknown-unknown/release/logging_engine.wasm"
+    );
+}
+
 /// ESPECIALLY LEAVE THESE TESTS ALONE
 #[test]
 fn fca00c_fast() {
     // Here we install and register the GameEngine contract in a default Soroban
     // environment, and build a client that can be used to invoke the contract.
     let env = Env::default();
+    let proxy_engine_id = env.register_contract_wasm(None, logging_contract::WASM);
     let engine_id = env.register_contract_wasm(None, GameEngineWASM);
-    let engine = GameEngine::new(&env, &engine_id);
+    let engine = GameEngine::new(&env, &proxy_engine_id);
+
+    logging_contract::Client::new(&env, &proxy_engine_id).wrap(&engine_id);

     // DON'T CHANGE THE FOLLOWING INIT() PARAMETERS
     // Once you've submitted your contract on the FCA00C site, we will invoke
@@ -63,12 +74,15 @@ fn fca00c_fast() {
     // We reset the budget so you have the best chance to not hit a TrapMemLimitExceeded or TrapCpuLimitExceeded error
     env.budget().reset();

-    solution.solve(&engine_id);
+    solution.solve(&proxy_engine_id);
+
+    let logs = env.logger().all();
+    println!("{}", logs.join("\n"));

     let points = engine.p_points();

     println!("Points: {}", points);
-    assert!(points >= 100);
+    assert!(points >= 1);
 }

 #[test]
Enter fullscreen mode Exit fullscreen mode

❗note how the original GameClient is initialized just with the logging_engine's id❗

Now run the tests:

❯ make test
cargo test fca00c_fast -- --nocapture
   Compiling soroban-asteroids-solution v0.0.0 (/home/paul/Code/fca00c - logging proxy/contracts/solution)
    Finished test [unoptimized + debuginfo] target(s) in 5.05s
     Running unittests src/lib.rs (target/debug/deps/soroban_asteroids_solution-5b0a3423b755223b)

running 1 test
invoker account is not configured
invoker account is not configured
πŸ—’οΈ logger engine taking notes
Points: 1
test test::fca00c_fast ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.44s

❯
Enter fullscreen mode Exit fullscreen mode

🏁 Great, we've made a proxy contract that allows us to keep the solution clean and does not need us to tinker with the original game_engine either πŸ‘!

Top comments (0)