catscope-rust-bot

Any custom feature requires changes in both repos. The optimizer (Go) decides what to send and when. The bot (Rust) decides what to do when it receives something. Neither side works without the other — there will be no error, just silence.

This page covers two mechanisms for extending the bot/optimizer communication beyond the built-in standard messages:

  • Boot-time args — settings carried to the bot as startup arguments when it is uploaded
  • Custom message types — data you define and exchange at runtime via evaluate() and OnCustom

For an explanation of why evaluate() exists and what advantage it gives you, see Bot Design.


Boot-time args

Boot-time args let you configure the bot before it starts. When the optimizer uploads the bot to a validator, it can include a list of arguments. The bot reads them in on_load the same way you read command-line arguments in any program.

The --echo flag is the reference example. When the optimizer is started with --echo, the bot starts in echo mode.

Step 1 — Add the flag to the optimizer CLI

File: optimizer/cmd/main.go

Add a field to RunCmd:

Echo bool `name:"echo" env:"ECHO" help:"enable echo mode in helloworldv1"`

The env:"ECHO" tag means either --echo or ECHO=true works. In Run(), build the args slice before calling simple.Create():

args := make([]string, 0)
if c.Echo {
    args = append(args, "--echo")
}
ms, err := mothership.Create(ctx, dialer, simple.Create(ctx, cancel, args))

Step 2 — Store and forward args in simple.go

File: optimizer/brain/simple/simple.go

Add args []string to hookSimple, update Create() to accept and store them, then in Init() replace the local args := make([]string, 0) with hs.args:

botImage, err := hs.useLocalImage(hs.ctx, localImage, hs.args, mEnv)

From here the args travel through mgrbot.Load()Image struct → Upload() → gRPC BotHeader → validator → WASI argv.

Step 3 — Read the arg in on_load (Rust)

File: catscope-rust-bot/src/brain/helloworldv1/state.rs

Add echo: bool to State (default false), then read it in on_load:

pub(crate) fn on_load(&mut self) {
    self.configuration.count += 1;
    assert_eq!(self.configuration.count, 1);
    let args: Vec<String> = std::env::args().skip(1).collect();
    self.state.echo = args.contains(&"--echo".to_string());
    log_info!("bot loaded; echo={}", self.state.echo);
}

skip(1) drops argv[0] (the program name). When this works you will see "bot loaded; echo=true" in the optimizer log every time the bot starts.


Outbound messages (bot → optimizer)

The bot can send data up to the optimizer at any time by pushing a message onto the outbound queue.

Define the message type

File: catscope-rust-bot/src/brain/helloworldv1/message.rs

Add a variant to CustomMessageOutbound and implement MessageSerializer:

pub(crate) enum CustomMessageOutbound {
    SlotReport(u64),
}

impl MessageSerializer for CustomMessageOutbound {
    fn len(&self) -> usize { 8 }
    fn is_empty(&self) -> bool { false }
    fn serialize(&self, buffer: &mut [u8]) {
        match self {
            Self::SlotReport(slot) => buffer.copy_from_slice(&slot.to_le_bytes()),
        }
    }
}

You only implement serialize(), which fills in the value portion of the wire frame. The framework handles the rest.

Queue the message in evaluate()

File: catscope-rust-bot/src/brain/helloworldv1/state.rs

evaluate() is called every slot. Push a message when your condition is met:

pub(crate) fn evaluate(&mut self) {
    if self.state.last_print.elapsed() >= Duration::from_secs(20) {
        self.state.last_print = Instant::now();
        if self.state.slot_report {
            self.q_msg.push_back(MessageSend::Custom(
                CustomMessageOutbound::SlotReport(self.state.last_slot),
            ));
        }
    }
}

Receive it in OnCustom (Go)

File: bot/solpipe/bidder/manager/bot/message.go

OnCustom is called every time the bot sends a CMD_CUSTOM message. Read the bytes and act:

func (in *internalBot) OnCustom(pair *catmsg.Pair) error {
    if len(pair.Value) == 8 {
        slot := binary.LittleEndian.Uint64(pair.Value)
        in.logger.Info(fmt.Sprintf("bot slot: %d", slot))
    }
    return nil
}

This is the place for your decision logic: read the signal, log it, update internal state, or push a new AdjustConfiguration back down.


Inbound messages (optimizer → bot at runtime)

The optimizer can also send custom messages down to the bot while it is running. The echo example shows how.

Define the inbound message type

File: catscope-rust-bot/src/brain/helloworldv1/message.rs

Add a variant to CustomMessageInbound and implement MessageDeserializer:

pub(crate) enum CustomMessageInbound {
    Blank,
    EchoRequest(String),
}

impl MessageDeserializer for CustomMessageInbound {
    fn deserialize(&mut self, key: &[u8], data: &[u8]) -> Result<(), CatscopeGuestError> {
        match key {
            b"bot_echo_v1" => {
                let s = std::str::from_utf8(data).unwrap_or("").to_string();
                *self = Self::EchoRequest(s);
            }
            _ => {
                *self = Self::Blank;
            }
        }
        Ok(())
    }
}

The key string ("bot_echo_v1") must match what the optimizer sends. Choose any name — it is your protocol.

Rust note: always write match self, never match *self. Using *self tells Rust to move the value out of the reference, which it refuses for types like String. With match self, the matched variable becomes a &String reference and all normal string methods work fine.

Handle it in on_message (Rust)

File: catscope-rust-bot/src/brain/helloworldv1/state.rs

Find the Custom arm in on_message and replace _ => {} with your handler:

MessageAction::Custom(CustomMessageInbound::EchoRequest(s)) => {
    if self.state.echo {
        self.q_msg.push_back(MessageSend::Custom(
            CustomMessageOutbound::EchoReply(s),
        ));
    }
}
MessageAction::Custom(CustomMessageInbound::Blank) => {}

Send the message from the optimizer (Go)

File: optimizer/brain/simple/instance.go

Use catmsg.MessageFromKeyValue to build the message, then instance.Send() to send it. Add a ticker to loopInstance:

echoC := time.After(10 * time.Second)

// inside the select loop:
case <-echoC:
    var msg catmsg.Message
    buildErr := catmsg.MessageFromKeyValue(
        []byte("bot_echo_v1"),
        []byte("hello"),
        &msg,
        catmsg.CMD_PAIR,
    )
    if buildErr == nil {
        instance.Send(msg)
    }
    echoC = time.After(10 * time.Second)

You do not need to manage nonces. loopBotStdin in connection.go overwrites the nonce on every message before it goes over the wire.

Read the reply in OnCustom (Go)

File: bot/solpipe/bidder/manager/bot/message.go

The reply arrives as CMD_CUSTOM. pair.Value contains the raw bytes:

func (in *internalBot) OnCustom(pair *catmsg.Pair) error {
    reply := string(pair.Value)
    in.logger.Info(fmt.Sprintf("echo reply: %s", reply))
    return nil
}

Note on pair.Key: the Rust bot sets the outbound key to [1] for all custom messages. If you add multiple outbound message types, encode the type inside pair.Value itself so the optimizer can distinguish them.


Summary

Step File What to change
Boot-time args cmd/main.go Add flag to RunCmd, build args slice
Boot-time args brain/simple/simple.go Store and forward args
Boot-time args helloworldv1/state.rs Read std::env::args() in on_load
Outbound message helloworldv1/message.rs Add variant to CustomMessageOutbound, implement MessageSerializer
Outbound message helloworldv1/state.rs Push message in evaluate()
Outbound message manager/bot/message.go Read bytes in OnCustom
Inbound message helloworldv1/message.rs Add variant to CustomMessageInbound, implement MessageDeserializer
Inbound message helloworldv1/state.rs Handle in on_message
Inbound message brain/simple/instance.go Send via catmsg.MessageFromKeyValue + instance.Send()