Skip to content

feat: add cross-chain message bot#20408

Open
just-mitch wants to merge 1 commit intomerge-train/spartanfrom
mitch/a-542-add-tx-bot-that-produces-cross-chain-messages
Open

feat: add cross-chain message bot#20408
just-mitch wants to merge 1 commit intomerge-train/spartanfrom
mitch/a-542-add-tx-bot-that-produces-cross-chain-messages

Conversation

@just-mitch
Copy link
Collaborator

@just-mitch just-mitch commented Feb 12, 2026

Adds a new bot mode (CROSS_CHAIN) that produces batch transactions of L2->L1 and L1->L2.

@just-mitch just-mitch force-pushed the mitch/a-542-add-tx-bot-that-produces-cross-chain-messages branch 5 times, most recently from ebd9f4b to 7c7f4fe Compare February 12, 2026 02:18
@just-mitch just-mitch marked this pull request as ready for review February 12, 2026 02:19
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This surprised and confused me. It felt like a bug, so I made this writeup. Does anyone have thoughts? I don't want to leave this file in, and would rather fix/track the bug if we believe one exists.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the simulator include pending L1-to-L2 messages

This is the correct option IMO. Simulation should be done in the same context as the next block that would be mined. We can do this in a separate PR though. We should also fix the e2e test for cross chain so we surface the issue.


__pycache__

*.local.md
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got tired of needing to avoid docs that I had created in the course of planning.

Copy link
Contributor

@spalladino spalladino left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for implementing this @just-mitch! I left some comments, including on the oddities.

Comment on lines 88 to 89
/** Seed a new L1→L2 message every N ticks (crosschain mode). */
l1ToL2SeedInterval: number;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: what's a tick in this context?

Now that I've read the bot code I understand. Why do we want this config? Isn't it enough to just work with seedcount and always top-up until that value?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, big simplification coming. Good call.

}, 120_000);
});

describe('cross-chain-bot store', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: these should be unit tests for the store

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the simulator include pending L1-to-L2 messages

This is the correct option IMO. Simulation should be done in the same context as the next block that would be mined. We can do this in a separate PR though. We should also fix the e2e test for cross chain so we surface the issue.

Comment on lines 160 to 161
default:
throw new Error(`Unsupported bot mode: [${this.config.botMode}]`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
default:
throw new Error(`Unsupported bot mode: [${this.config.botMode}]`);
default:
const _exhaustive: never = this.config.botMode
throw new Error(`Unsupported bot mode: [${this.config.botMode}]`);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming config.botMode is an union type and not a plain string

Comment on lines +577 to +606
variable "BOT_CROSS_CHAIN_MNEMONIC_START_INDEX" {
description = "The cross-chain bot mnemonic start index"
type = string
default = ""
}

variable "BOT_CROSS_CHAIN_REPLICAS" {
description = "Number of cross-chain bot replicas to deploy (0 to disable)"
type = number
default = 0
}

variable "BOT_CROSS_CHAIN_TX_INTERVAL_SECONDS" {
description = "Interval in seconds between cross-chain bot transactions"
type = number
default = 10
}

variable "BOT_CROSS_CHAIN_FOLLOW_CHAIN" {
description = "Cross-chain bot follow-chain mode"
type = string
default = "PENDING"
}

variable "BOT_CROSS_CHAIN_L2_PRIVATE_KEY" {
description = "Private key for the cross-chain bot (hex string starting with 0x)"
type = string
nullable = true
default = null
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up we're missing a BOT_CROSS_CHAIN_PXE_SYNC_CHAIN_TIP (see #20392)

Comment on lines 7 to 9
* pendingMessages (Map) In-memory mirror of BotStore.
* Tracks L1->L2 messages that have been
* seeded on L1 but not yet consumed on L2.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd simplify and keep a single source of truth

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had the same thought

protected override async onTxMined(receipt: TxReceipt, logCtx: object): Promise<void> {
// Verify L2→L1 messages appeared in this tx's effects
if (receipt.blockNumber !== undefined) {
const block = await this.node.getBlock(receipt.blockNumber);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: you can query node.getTxEffect directly

Comment on lines 217 to 220
// Message not found in any block — stale, remove it
this.pendingMessages.delete(msg.msgHash);
await this.store.deleteL1ToL2Message(msg.msgHash);
this.log.warn(`Removed stale L1→L2 message ${msg.msgHash}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think isL1ToL2MessageReady throw if the message is stale, can you confirm?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yep. I have it now just detecting based on timestamp, and log.warning. Not sure if we can do better there? I would also like to plumb some basic metrics through these bots so we can see their own understanding of state. That is, tracking how often messages become stale would be good to know.

Comment on lines 184 to 190
// Clean up consumed L1→L2 message from store
if (this.lastConsumedMsg) {
this.pendingMessages.delete(this.lastConsumedMsg.msgHash);
await this.store.deleteL1ToL2Message(this.lastConsumedMsg.msgHash);
this.l1ToL2Consumed++;
this.lastConsumedMsg = undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the bot works with FOLLOW_CHAIN=NONE. If it's not instructed to wait for the tx to be mined, then onTxMined never gets called, and the pending l1 to l2 messages are never cleared.

I think this needs a few changes if we want to support that mode, since we could have more than one tx in-flight at any given time. We could have a collection of in-flight messages that we avoid picking from during getReadyL1ToL2Message. Or maybe the easiest solution is to just clean the message from the store as soon as we send the L2 tx that consumes it, regardless of it being successful or not.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. I think I'm just going to prevent the crosschain bot from using FOLLOW_CHAIN=NONE

this.pendingMessages.size < this.config.l1ToL2SeedCount
) {
try {
await this.seedNewL1ToL2Message();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about not awaiting here, so we don't block the bot loop for at least one L1 slot until the message gets mined?

@just-mitch just-mitch force-pushed the mitch/a-542-add-tx-bot-that-produces-cross-chain-messages branch from 7c7f4fe to e7d55bf Compare February 12, 2026 15:58
const pendingMessages = await this.store.getUnconsumedL1ToL2Messages();

// Send an L1→L2 message if we're below the threshold and not already seeding one
if (pendingMessages.length < this.config.l1ToL2SeedCount && !this.pendingSeedPromise) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely mean there is never more than 1 in-flight L1-> L2 message, but I'm fine with that for this PR. As noted elsewhere, I'd like to get some basic metrics up to track the backpressure before tuning message creation/consumption rates.

Adds a new bot mode that produces cross-chain L2-to-L1 messages, including
terraform/spartan deployment config, e2e tests, and supporting infrastructure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@just-mitch just-mitch force-pushed the mitch/a-542-add-tx-bot-that-produces-cross-chain-messages branch from e7d55bf to 2364885 Compare February 12, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants