mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-27 12:21:17 +00:00
feat(cross-zone)!: pin ping_sender's outbox to a genesis-seeded config
BREAKING CHANGE: SenderInstruction::Send drops outbox_program_id and takes the sender config PDA as its first account, changing both the instruction encoding and the account list. ping_sender's image id moves, relocating its PDAs and requiring a fresh genesis. Sequencer, indexer and every peer zone must upgrade together: a stale decoder re-derives a different dispatch and reports Forged.
This commit is contained in:
Generated
+1
@@ -7704,6 +7704,7 @@ name = "ping_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ use integration_tests::{
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use tokio::test;
|
||||
@@ -104,7 +106,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
@@ -112,15 +113,11 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(
|
||||
outbox_id,
|
||||
programs::ping_sender().id(),
|
||||
&target_zone,
|
||||
ordinal,
|
||||
);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
@@ -22,7 +22,9 @@ use lee::{
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use lee_core::account::Account;
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, outbox_bytes, ping_record_pda, read_outbox, sender_config_account_id,
|
||||
};
|
||||
|
||||
const INITIAL_BALANCE: u128 = 100;
|
||||
const LOCK_AMOUNT: u128 = 30;
|
||||
@@ -94,6 +96,43 @@ fn seed_wrapped_config(state: &mut V03State) {
|
||||
)]);
|
||||
}
|
||||
|
||||
/// Seeds the ping-sender config account pinning the real outbox, matching what
|
||||
/// genesis seeds for a real zone.
|
||||
fn seed_ping_sender_config(state: &mut V03State) {
|
||||
let sender_id = programs::ping_sender().id();
|
||||
*state = std::mem::replace(state, V03State::new()).with_public_accounts([(
|
||||
sender_config_account_id(sender_id),
|
||||
Account {
|
||||
program_owner: sender_id,
|
||||
data: outbox_bytes(programs::cross_zone_outbox().id())
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("outbox id fits in account data"),
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
}
|
||||
|
||||
/// A `ping_sender::Send` carrying `payload` to `target_zone`, over the accounts
|
||||
/// given rather than the correct ones, so tests can vary them.
|
||||
fn send_tx(accounts: Vec<AccountId>, target_zone: [u8; 32], ordinal: u32) -> PublicTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
|
||||
payload: b"ping".to_vec(),
|
||||
})
|
||||
.expect("serialize ping instruction");
|
||||
let send = ping_core::SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(),
|
||||
ordinal,
|
||||
};
|
||||
let message = Message::try_new(programs::ping_sender().id(), accounts, vec![], send)
|
||||
.expect("build ping_sender message");
|
||||
PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]))
|
||||
}
|
||||
|
||||
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
|
||||
/// payload (risc0 words, little-endian bytes).
|
||||
fn mint_payload() -> Vec<u8> {
|
||||
@@ -423,6 +462,7 @@ fn two_emitters_share_an_ordinal_without_colliding() {
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
seed_ping_sender_config(&mut state);
|
||||
|
||||
let lock_slot = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal);
|
||||
let send_slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal);
|
||||
@@ -436,23 +476,12 @@ fn two_emitters_share_an_ordinal_without_colliding() {
|
||||
.expect("the lock executes");
|
||||
state.apply_state_diff(diff);
|
||||
|
||||
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
|
||||
payload: b"ping".to_vec(),
|
||||
})
|
||||
.expect("serialize ping instruction");
|
||||
let send = ping_core::SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone: zone_b,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(),
|
||||
let send = send_tx(
|
||||
vec![sender_config_account_id(sender_id), send_slot],
|
||||
zone_b,
|
||||
ordinal,
|
||||
};
|
||||
let message = Message::try_new(sender_id, vec![send_slot], vec![], send)
|
||||
.expect("build ping_sender message");
|
||||
let send_tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
|
||||
|
||||
let send_diff = ValidatedStateDiff::from_public_transaction(&send_tx, &state, 2, 0)
|
||||
);
|
||||
let send_diff = ValidatedStateDiff::from_public_transaction(&send, &state, 2, 0)
|
||||
.expect("the send executes into its own slot, not the lock's");
|
||||
|
||||
let record = OutboxRecord::from_bytes(
|
||||
@@ -472,6 +501,135 @@ fn two_emitters_share_an_ordinal_without_colliding() {
|
||||
assert_eq!(lock_record.emitter, bridge_lock_id);
|
||||
}
|
||||
|
||||
/// A caller can no longer aim an emission at a program of their own and still
|
||||
/// succeed, leaving no record of it. With the program no longer an instruction
|
||||
/// field, the account is the only way left to try.
|
||||
#[test]
|
||||
fn a_send_into_a_foreign_outbox_slot_is_rejected() {
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let zone_b = [2_u8; 32];
|
||||
let ordinal = 0;
|
||||
|
||||
let mut state = base_state();
|
||||
seed_ping_sender_config(&mut state);
|
||||
|
||||
// A slot under some other program, which is what the caller would have to
|
||||
// pass to reach it.
|
||||
let foreign_slot = outbox_pda([3; 8], sender_id, &zone_b, ordinal);
|
||||
let send = send_tx(
|
||||
vec![sender_config_account_id(sender_id), foreign_slot],
|
||||
zone_b,
|
||||
ordinal,
|
||||
);
|
||||
|
||||
// Refused inside the pinned outbox, not by the sender: the chained call goes
|
||||
// there whatever account the caller passes, which is the point.
|
||||
let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else {
|
||||
panic!("a send into a slot outside the pinned outbox must not execute");
|
||||
};
|
||||
assert!(
|
||||
format!("{err:?}").contains("Account must be the outbox PDA"),
|
||||
"rejected for the wrong reason: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// An emitter with no pin cannot fall back to a caller-named outbox: it stops
|
||||
/// emitting. The state a zone reaches by skipping the genesis init.
|
||||
#[test]
|
||||
fn a_send_before_the_pin_is_set_is_rejected() {
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let zone_b = [2_u8; 32];
|
||||
let ordinal = 0;
|
||||
|
||||
let state = base_state();
|
||||
let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal);
|
||||
let send = send_tx(
|
||||
vec![sender_config_account_id(sender_id), slot],
|
||||
zone_b,
|
||||
ordinal,
|
||||
);
|
||||
|
||||
let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else {
|
||||
panic!("a send with no outbox pinned must not execute");
|
||||
};
|
||||
assert!(
|
||||
format!("{err:?}").contains("config account holds an outbox program id"),
|
||||
"rejected for the wrong reason: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The config is read by address, so substituting another account for it fails
|
||||
/// rather than pinning the outbox to whatever that account happens to hold.
|
||||
#[test]
|
||||
fn a_send_with_a_substituted_config_account_is_rejected() {
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let zone_b = [2_u8; 32];
|
||||
let ordinal = 0;
|
||||
|
||||
let mut state = base_state();
|
||||
seed_ping_sender_config(&mut state);
|
||||
|
||||
let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal);
|
||||
let send = send_tx(vec![ping_record_pda(sender_id), slot], zone_b, ordinal);
|
||||
|
||||
let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else {
|
||||
panic!("a send over a substituted config account must not execute");
|
||||
};
|
||||
assert!(
|
||||
format!("{err:?}").contains("must be the ping-sender config PDA"),
|
||||
"rejected for the wrong reason: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Written once: an identical re-init has to succeed, since genesis is replayed
|
||||
/// during multi-sequencer reconstruction, while one naming a different outbox has
|
||||
/// to fail, or anyone could redirect every emission on the zone after genesis.
|
||||
#[test]
|
||||
fn the_outbox_pin_is_written_once_and_replayable() {
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let config_id = sender_config_account_id(sender_id);
|
||||
|
||||
// Unsigned and nonce-free, as genesis builds it: the config PDA has no signer.
|
||||
let init = |outbox: lee_core::program::ProgramId| {
|
||||
let message = Message::try_new(
|
||||
sender_id,
|
||||
vec![config_id],
|
||||
vec![],
|
||||
ping_core::SenderInstruction::InitConfig {
|
||||
outbox_program_id: outbox,
|
||||
},
|
||||
)
|
||||
.expect("build InitConfig message");
|
||||
PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]))
|
||||
};
|
||||
|
||||
let mut state = base_state();
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
|
||||
let first = init(outbox_id);
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0)
|
||||
.expect("the first init claims the config PDA");
|
||||
state.apply_state_diff(diff);
|
||||
assert_eq!(
|
||||
read_outbox(&state.get_account_by_id(config_id).data.into_inner()),
|
||||
Some(outbox_id),
|
||||
"the config pins the outbox after genesis"
|
||||
);
|
||||
|
||||
ValidatedStateDiff::from_public_transaction(&init(outbox_id), &state, 2, 0)
|
||||
.expect("replaying the identical init is a no-op, not a failure");
|
||||
|
||||
let Err(err) = ValidatedStateDiff::from_public_transaction(&init([3; 8]), &state, 3, 0) else {
|
||||
panic!("a re-init naming a different outbox must not execute");
|
||||
};
|
||||
assert!(
|
||||
format!("{err:?}").contains("already pins a different outbox"),
|
||||
"rejected for the wrong reason: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject)
|
||||
/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient.
|
||||
#[test]
|
||||
|
||||
@@ -21,7 +21,9 @@ use integration_tests::{
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use tokio::test;
|
||||
@@ -109,7 +111,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
@@ -117,15 +118,11 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(
|
||||
outbox_id,
|
||||
programs::ping_sender().id(),
|
||||
&target_zone,
|
||||
ordinal,
|
||||
);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,9 @@ use integration_tests::{
|
||||
};
|
||||
use lee::{AccountId, PublicTransaction, public_transaction::Message};
|
||||
use lee_core::program::ProgramId;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use tokio::test;
|
||||
@@ -190,7 +192,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
@@ -198,15 +199,11 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(
|
||||
outbox_id,
|
||||
programs::ping_sender().id(),
|
||||
&target_zone,
|
||||
ordinal,
|
||||
);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
@@ -64,13 +64,18 @@ pub fn is_sequencer_only_program(program_id: ProgramId) -> bool {
|
||||
#[must_use]
|
||||
pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option<Emission> {
|
||||
if program_id == programs::ping_sender().id() {
|
||||
let ping_core::SenderInstruction::Send {
|
||||
// Not every transaction to an emitter emits: `InitConfig` is one of its
|
||||
// instructions, so a non-`Send` decode is an ordinary non-emitting tx.
|
||||
let Ok(ping_core::SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
..
|
||||
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
|
||||
}) = risc0_zkvm::serde::from_slice(instruction_data)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(Emission {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
@@ -215,6 +220,20 @@ pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction {
|
||||
)
|
||||
}
|
||||
|
||||
/// The genesis transaction that pins the outbox `ping_sender` chains into,
|
||||
/// without importing the outbox id into the guest.
|
||||
#[must_use]
|
||||
pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction {
|
||||
let ping_sender_id = programs::ping_sender().id();
|
||||
genesis_public_tx(
|
||||
ping_sender_id,
|
||||
vec![ping_core::sender_config_account_id(ping_sender_id)],
|
||||
ping_core::SenderInstruction::InitConfig {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction`
|
||||
/// on `program_id` over `account_ids`.
|
||||
fn genesis_public_tx<I: Serialize>(
|
||||
|
||||
@@ -746,7 +746,6 @@ mod tests {
|
||||
fn emission(payload: &[u8]) -> LeeTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_zone: SELF_ZONE,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
|
||||
@@ -10,3 +10,6 @@ workspace = true
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -5,6 +5,7 @@ use lee_core::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/";
|
||||
const SENDER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingSenderCfg/0000000/";
|
||||
|
||||
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -12,17 +13,29 @@ pub enum ReceiverInstruction {
|
||||
Record { payload: Vec<u8> },
|
||||
}
|
||||
|
||||
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
|
||||
/// Instruction to `ping_sender`. `Send`'s emission fields are forwarded verbatim
|
||||
/// into `cross_zone_outbox::Instruction::Emit`.
|
||||
///
|
||||
/// Variants are append-only. risc0 serde encodes the variant as a bare leading
|
||||
/// tag word, so inserting one ahead of `Send` shifts every existing encoding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SenderInstruction {
|
||||
/// Emit a cross-zone message through the pinned outbox.
|
||||
///
|
||||
/// Required accounts (2): the sender config PDA, then the outbox PDA.
|
||||
Send {
|
||||
outbox_program_id: ProgramId,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
},
|
||||
/// Pins the outbox program, written once into a default config PDA at
|
||||
/// genesis. A re-run naming a different outbox is refused; an identical one
|
||||
/// is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the sender config PDA.
|
||||
InitConfig { outbox_program_id: ProgramId },
|
||||
}
|
||||
|
||||
/// The account a `ping_receiver` records the latest delivered payload into.
|
||||
@@ -36,3 +49,65 @@ pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId {
|
||||
pub const fn ping_record_seed() -> PdaSeed {
|
||||
PdaSeed::new(PING_RECORD_SEED)
|
||||
}
|
||||
|
||||
/// PDA holding the outbox program id, seeded at genesis so the guest can pin the
|
||||
/// program it chains into without importing the outbox image id.
|
||||
#[must_use]
|
||||
pub fn sender_config_account_id(sender_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&sender_id, &sender_config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn sender_config_seed() -> PdaSeed {
|
||||
PdaSeed::new(SENDER_CONFIG_SEED)
|
||||
}
|
||||
|
||||
/// Encodes the pinned outbox program id for the config account's data.
|
||||
#[must_use]
|
||||
pub fn outbox_bytes(outbox_program_id: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (word, chunk) in outbox_program_id.iter().zip(bytes.chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the pinned outbox program id from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_outbox(data: &[u8]) -> Option<ProgramId> {
|
||||
if data.len() < 32 {
|
||||
return None;
|
||||
}
|
||||
let mut outbox_program_id = [0_u32; 8];
|
||||
for (word, chunk) in outbox_program_id.iter_mut().zip(data[..32].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
Some(outbox_program_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `extract_emission` decodes `Send` off peer transactions, so its tag word is
|
||||
/// wire format: a variant inserted ahead of it would silently shift every
|
||||
/// existing encoding.
|
||||
#[test]
|
||||
fn send_is_the_first_variant() {
|
||||
let send = SenderInstruction::Send {
|
||||
target_zone: [7; 32],
|
||||
target_program_id: [1; 8],
|
||||
target_accounts: vec![],
|
||||
payload: vec![],
|
||||
ordinal: 0,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&send).expect("Send serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_id_round_trips() {
|
||||
let outbox: ProgramId = [9; 8];
|
||||
assert_eq!(read_outbox(&outbox_bytes(outbox)), Some(outbox));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{
|
||||
AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput,
|
||||
read_lee_inputs,
|
||||
},
|
||||
};
|
||||
use ping_core::{
|
||||
SenderInstruction, outbox_bytes, read_outbox, sender_config_account_id, sender_config_seed,
|
||||
};
|
||||
use ping_core::SenderInstruction;
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
@@ -21,19 +26,63 @@ fn main() {
|
||||
"ping_sender is only invoked as a top-level user transaction"
|
||||
);
|
||||
|
||||
let SenderInstruction::Send {
|
||||
outbox_program_id,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} = instruction;
|
||||
match instruction {
|
||||
SenderInstruction::Send {
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} => send(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
),
|
||||
SenderInstruction::InitConfig { outbox_program_id } => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
outbox_program_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// The single account is the outbox PDA the chained call writes into; the
|
||||
// outbox claims it, so ping_sender forwards it unchanged.
|
||||
let [outbox] =
|
||||
<[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account");
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the emission fields are passed through verbatim"
|
||||
)]
|
||||
fn send(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
) {
|
||||
// pre_states: [config PDA, outbox PDA]. The outbox claims its own slot, so
|
||||
// ping_sender forwards it unchanged.
|
||||
let [config, outbox] = <[AccountWithMetadata; 2]>::try_from(pre_states)
|
||||
.expect("Send requires the config and outbox accounts");
|
||||
|
||||
// Pinned rather than caller-named: chaining elsewhere would let an emission
|
||||
// skip the real outbox and leave no record of itself.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
sender_config_account_id(self_program_id),
|
||||
"first account must be the ping-sender config PDA"
|
||||
);
|
||||
let outbox_program_id = read_outbox(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an outbox program id");
|
||||
|
||||
let call = ChainedCall::new(
|
||||
outbox_program_id,
|
||||
@@ -47,13 +96,65 @@ fn main() {
|
||||
},
|
||||
);
|
||||
|
||||
let config_post = AccountPostState::new(config.account.clone());
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![outbox.clone()],
|
||||
vec![AccountPostState::new(outbox.account)],
|
||||
vec![config, outbox.clone()],
|
||||
vec![config_post, AccountPostState::new(outbox.account)],
|
||||
)
|
||||
.with_chained_calls(vec![call])
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the outbox program id into the config PDA exactly once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
outbox_program_id: ProgramId,
|
||||
) {
|
||||
// pre_states: [config PDA].
|
||||
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("InitConfig requires the config account");
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
sender_config_account_id(self_program_id),
|
||||
"account must be the ping-sender config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned one must already pin exactly this outbox, since
|
||||
// genesis is replayed onto seeded state during multi-sequencer reconstruction.
|
||||
// `new_claimed_if_default` alone would not stop a later self-owned rewrite.
|
||||
if config.account != Account::default() {
|
||||
assert_eq!(
|
||||
config.account.program_owner, self_program_id,
|
||||
"ping-sender config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
outbox_bytes(outbox_program_id).to_vec(),
|
||||
"ping-sender config already pins a different outbox"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = outbox_bytes(outbox_program_id)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("outbox id fits in account data");
|
||||
let config_post =
|
||||
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(sender_config_seed()));
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config],
|
||||
vec![config_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
@@ -707,7 +707,6 @@ mod tests {
|
||||
fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_zone: SELF_ZONE,
|
||||
target_program_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
|
||||
@@ -1522,13 +1522,15 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
);
|
||||
|
||||
// Config txs seed the config accounts by transaction, so every node
|
||||
// reconstructs them by replaying the genesis block. The wrapped-token minter is
|
||||
// initialized on every zone (wrapped_token is a builtin), since its InitConfig
|
||||
// is user-callable and a config PDA left default would be claimable by anyone as
|
||||
// the first initializer (a minter hijack). The inbox allowlist is initialized
|
||||
// only on receiving zones; the inbox is sequencer-only, so its default config
|
||||
// PDA is not user-claimable, merely unused until the zone receives.
|
||||
// reconstructs them by replaying the genesis block. The wrapped-token minter and
|
||||
// the ping-sender outbox pin are initialized on every zone: both are builtins
|
||||
// with a user-callable InitConfig, so a config PDA left default is claimable by
|
||||
// the first initializer, hijacking the minter or repointing the emitter's
|
||||
// outbox. The inbox allowlist is initialized only on receiving zones; the inbox
|
||||
// is sequencer-only, so its default config PDA is not user-claimable, merely
|
||||
// unused until the zone receives.
|
||||
let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx());
|
||||
let ping_sender_config_tx = std::iter::once(cross_zone::build_ping_sender_init_config_tx());
|
||||
let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| {
|
||||
let self_zone = *config.bedrock_config.channel_id.as_ref();
|
||||
cross_zone::build_inbox_init_config_tx(self_zone, cross_zone)
|
||||
@@ -1549,6 +1551,7 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
});
|
||||
|
||||
let genesis_txs = wrapped_token_config_tx
|
||||
.chain(ping_sender_config_tx)
|
||||
.chain(inbox_config_tx)
|
||||
.chain(supply_txs)
|
||||
.chain(std::iter::once(clock_invocation(0)))
|
||||
|
||||
@@ -61,7 +61,9 @@ use lee::{
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use log::{info, warn};
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, sender_config_account_id,
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use test_fixtures::{
|
||||
@@ -522,7 +524,9 @@ fn decode_inbox_text(instruction_data: &[u32]) -> Option<String> {
|
||||
fn decode_send_ordinal(instruction_data: &[u32]) -> Option<u32> {
|
||||
let instruction: SenderInstruction =
|
||||
risc0_zkvm::serde::from_slice::<SenderInstruction, u32>(instruction_data).ok()?;
|
||||
let SenderInstruction::Send { ordinal, .. } = instruction;
|
||||
let SenderInstruction::Send { ordinal, .. } = instruction else {
|
||||
return None;
|
||||
};
|
||||
Some(ordinal)
|
||||
}
|
||||
|
||||
@@ -554,7 +558,6 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let send = SenderInstruction::Send {
|
||||
outbox_program_id: outbox_id,
|
||||
target_zone: other_zone,
|
||||
target_program_id: receiver_id,
|
||||
target_accounts: vec![ping_record_pda(receiver_id).into_value()],
|
||||
@@ -562,15 +565,11 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(
|
||||
outbox_id,
|
||||
programs::ping_sender().id(),
|
||||
&other_zone,
|
||||
ordinal,
|
||||
);
|
||||
let sender_id = programs::ping_sender().id();
|
||||
let outbox_account = outbox_pda(outbox_id, sender_id, &other_zone, ordinal);
|
||||
let message = Message::try_new(
|
||||
programs::ping_sender().id(),
|
||||
vec![outbox_account],
|
||||
sender_id,
|
||||
vec![sender_config_account_id(sender_id), outbox_account],
|
||||
vec![],
|
||||
send,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user