mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-25 03:11:21 +00:00
Merge pull request #697 from logos-blockchain/moudy/cross-zone-outbox-log
fix(cross-zone)!: make an outbox slot a write-once log entry keyed by its emitter
This commit is contained in:
Generated
+7
@@ -1225,6 +1225,7 @@ name = "bridge_lock_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -2002,6 +2003,7 @@ dependencies = [
|
||||
"log",
|
||||
"ping_core",
|
||||
"programs",
|
||||
"rand 0.8.6",
|
||||
"risc0-zkvm",
|
||||
"sequencer_service_rpc",
|
||||
"serde",
|
||||
@@ -7746,7 +7748,9 @@ dependencies = [
|
||||
name = "ping_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -7754,6 +7758,7 @@ dependencies = [
|
||||
name = "ping_receiver_program"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cross_zone_inbox_core",
|
||||
"lee_core",
|
||||
"ping_core",
|
||||
]
|
||||
@@ -12340,6 +12345,7 @@ dependencies = [
|
||||
name = "wrapped_token_core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"lee_core",
|
||||
"risc0-zkvm",
|
||||
"serde",
|
||||
@@ -12349,6 +12355,7 @@ dependencies = [
|
||||
name = "wrapped_token_program"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cross_zone_inbox_core",
|
||||
"lee_core",
|
||||
"wrapped_token_core",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,12 +9,10 @@
|
||||
//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged;
|
||||
//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new.
|
||||
//!
|
||||
//! Not production-safe. The inbox allowlist gates the target program, not the
|
||||
//! source emitter, and `extract_emission` recognizes any known emitter, so in a
|
||||
//! zone that allows `wrapped_token` as a target a permissionless `ping_sender`
|
||||
//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe
|
||||
//! needs source verification, where a value-bearing target checks the message
|
||||
//! originated from `bridge_lock`; that is out of scope for the demo.
|
||||
//! A `ping_sender` send carrying a `wrapped_token::Mint` is refused as long as no
|
||||
//! operator writes a `(ping_sender, wrapped_token)` route: the allowlist is a
|
||||
//! source-and-target pair. Nothing forbids writing that route, and the token
|
||||
//! still trusts the table rather than checking its own sources, which is #673.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -163,14 +161,14 @@ fn build_lock_tx(
|
||||
target_program_id: wrapped_token_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id: outbox_id,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let accounts = vec![
|
||||
bridge_lock_core::config_account_id(bridge_lock_id),
|
||||
holder_id,
|
||||
bridge_lock_core::escrow_account_id(bridge_lock_id),
|
||||
outbox_pda(outbox_id, &target_zone, ordinal),
|
||||
outbox_pda(outbox_id, bridge_lock_id, &target_zone, ordinal),
|
||||
];
|
||||
// One nonce per signature: the holder signs, at its genesis nonce 0.
|
||||
let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock)
|
||||
|
||||
@@ -19,7 +19,10 @@ use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::config::{self, SequencerPartialConfig};
|
||||
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, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use test_fixtures::{
|
||||
@@ -119,18 +122,21 @@ 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()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_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,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,10 @@ 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, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::RpcClient as _;
|
||||
use test_fixtures::{
|
||||
@@ -112,18 +115,21 @@ 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()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_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,10 @@ 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, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
|
||||
use tokio::test;
|
||||
@@ -190,18 +193,21 @@ 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()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_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,
|
||||
)
|
||||
|
||||
+115
-31
@@ -9,12 +9,10 @@
|
||||
//! own block-reading, emission-extraction, delivery-building, and trust model; a
|
||||
//! shared trait is best lifted from that first real adapter, not from this one.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer};
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id,
|
||||
inbox_seen_shard_account_id,
|
||||
inbox_seen_shard_account_id, inbox_source_marker_account_id,
|
||||
};
|
||||
use lee_core::{
|
||||
account::{Account, AccountId, Balance},
|
||||
@@ -64,13 +62,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,
|
||||
@@ -78,13 +81,16 @@ pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Opti
|
||||
payload,
|
||||
})
|
||||
} else if program_id == programs::bridge_lock().id() {
|
||||
let bridge_lock_core::Instruction::Lock {
|
||||
let Ok(bridge_lock_core::Instruction::Lock {
|
||||
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,
|
||||
@@ -103,13 +109,21 @@ fn build_inbox_dispatch_tx(
|
||||
msg: &CrossZoneMessage,
|
||||
target_account_ids: Vec<AccountId>,
|
||||
) -> lee::PublicTransaction {
|
||||
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2));
|
||||
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(3));
|
||||
account_ids.push(inbox_config_account_id(inbox_id));
|
||||
account_ids.push(inbox_seen_shard_account_id(
|
||||
inbox_id,
|
||||
&msg.src_zone,
|
||||
msg.src_block_id,
|
||||
));
|
||||
// Declared here rather than derived by the guest, since a guest cannot
|
||||
// conjure an account. Both the watcher and the verifier build it through this
|
||||
// one function, so they cannot disagree about the source a target will see.
|
||||
account_ids.push(inbox_source_marker_account_id(
|
||||
inbox_id,
|
||||
&msg.src_zone,
|
||||
msg.src_program_id,
|
||||
));
|
||||
account_ids.extend(target_account_ids);
|
||||
|
||||
let message = lee::public_transaction::Message::try_new(
|
||||
@@ -156,33 +170,18 @@ pub fn build_dispatch_from_emission(
|
||||
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
|
||||
}
|
||||
|
||||
/// The inbox config a zone derives from its cross-zone config: the per-peer
|
||||
/// delivery routes plus its own zone id.
|
||||
fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig {
|
||||
let mut allowed_routes = BTreeMap::new();
|
||||
for peer in &cross_zone.peers {
|
||||
allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone());
|
||||
}
|
||||
InboxConfig {
|
||||
self_zone,
|
||||
allowed_routes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The genesis transaction that initializes this zone's inbox config PDA.
|
||||
///
|
||||
/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the
|
||||
/// same account on every node, keeping their state consistent.
|
||||
/// The operator's per-peer routes no longer live here. They are fanned out into
|
||||
/// each target program's own config, so all the inbox keeps is its zone id.
|
||||
/// Replaying this seeds the same account on every node.
|
||||
#[must_use]
|
||||
pub fn build_inbox_init_config_tx(
|
||||
self_zone: ZoneId,
|
||||
cross_zone: &CrossZoneConfig,
|
||||
) -> lee::PublicTransaction {
|
||||
pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> lee::PublicTransaction {
|
||||
let inbox_id = programs::cross_zone_inbox().id();
|
||||
genesis_public_tx(
|
||||
inbox_id,
|
||||
vec![inbox_config_account_id(inbox_id)],
|
||||
Instruction::InitConfig(inbox_config(self_zone, cross_zone)),
|
||||
Instruction::InitConfig(InboxConfig { self_zone }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,19 +201,104 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId,
|
||||
}
|
||||
|
||||
/// The genesis transaction that pins the cross-zone inbox as the wrapped-token
|
||||
/// minter, without importing the inbox id into the guest.
|
||||
/// minter and names the peer sources it may mint for, without importing either id
|
||||
/// into the guest.
|
||||
///
|
||||
/// The sources are the operator's own peer routes aimed at this token, moved from
|
||||
/// the inbox's allowlist to the token's own config: the same information, enforced
|
||||
/// by the program that owns the value. A zone with no peers gets an empty list,
|
||||
/// which authorizes nothing, and the config is still seeded so its PDA cannot be
|
||||
/// claimed by a first initializer.
|
||||
#[must_use]
|
||||
pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction {
|
||||
pub fn build_wrapped_token_init_config_tx(
|
||||
cross_zone: Option<&CrossZoneConfig>,
|
||||
) -> lee::PublicTransaction {
|
||||
let wrapped_token_id = programs::wrapped_token().id();
|
||||
let sources = cross_zone
|
||||
.map(|cross_zone| {
|
||||
cross_zone
|
||||
.peers
|
||||
.iter()
|
||||
.flat_map(|peer| {
|
||||
peer.allowed_routes
|
||||
.iter()
|
||||
.filter(|route| route.target_program_id == wrapped_token_id)
|
||||
.map(|route| (peer.channel_id, route.src_program_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
genesis_public_tx(
|
||||
wrapped_token_id,
|
||||
vec![wrapped_token_core::config_account_id(wrapped_token_id)],
|
||||
wrapped_token_core::Instruction::InitConfig {
|
||||
wrapped_token_core::Instruction::InitConfig(wrapped_token_core::WrappedTokenConfig {
|
||||
minter: programs::cross_zone_inbox().id(),
|
||||
sources,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The genesis transaction that pins the outbox `bridge_lock` chains into and the
|
||||
/// wrapped token it mints, without importing either id into the guest.
|
||||
#[must_use]
|
||||
pub fn build_bridge_lock_init_config_tx() -> lee::PublicTransaction {
|
||||
let bridge_lock_id = programs::bridge_lock().id();
|
||||
genesis_public_tx(
|
||||
bridge_lock_id,
|
||||
vec![bridge_lock_core::config_account_id(bridge_lock_id)],
|
||||
bridge_lock_core::Instruction::InitConfig {
|
||||
outbox_program_id: programs::cross_zone_outbox().id(),
|
||||
target_program_id: programs::wrapped_token().id(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The genesis transaction naming the peer sources `ping_receiver` accepts a
|
||||
/// delivery from, fanned out of the operator's routes exactly as the wrapped
|
||||
/// token's is.
|
||||
#[must_use]
|
||||
pub fn build_ping_receiver_init_config_tx(
|
||||
cross_zone: Option<&CrossZoneConfig>,
|
||||
) -> lee::PublicTransaction {
|
||||
let receiver_id = programs::ping_receiver().id();
|
||||
let sources = cross_zone
|
||||
.map(|cross_zone| {
|
||||
cross_zone
|
||||
.peers
|
||||
.iter()
|
||||
.flat_map(|peer| {
|
||||
peer.allowed_routes
|
||||
.iter()
|
||||
.filter(|route| route.target_program_id == receiver_id)
|
||||
.map(|route| (peer.channel_id, route.src_program_id))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
genesis_public_tx(
|
||||
receiver_id,
|
||||
vec![ping_core::receiver_config_account_id(receiver_id)],
|
||||
ping_core::ReceiverInstruction::InitConfig(ping_core::ReceiverConfig {
|
||||
deliverer: programs::cross_zone_inbox().id(),
|
||||
sources,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction`
|
||||
/// on `program_id` over `account_ids`.
|
||||
fn genesis_public_tx<I: Serialize>(
|
||||
|
||||
@@ -718,7 +718,7 @@ mod tests {
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||
use logos_blockchain_zone_sdk::ZoneBlock;
|
||||
use ping_core::{SenderInstruction, ping_record_pda};
|
||||
use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -746,10 +746,12 @@ 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()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload: payload.to_vec(),
|
||||
ordinal: 0,
|
||||
};
|
||||
@@ -846,7 +848,10 @@ mod tests {
|
||||
src_program_id: programs::ping_sender().id(),
|
||||
},
|
||||
receiver_id,
|
||||
&[ping_record_pda(receiver_id).into_value()],
|
||||
&[
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload.to_vec(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -10,3 +10,6 @@ workspace = true
|
||||
[dependencies]
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -9,23 +9,41 @@ use lee_core::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/";
|
||||
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockCfg/0000000/";
|
||||
|
||||
/// Variants are append-only. risc0 serde encodes the variant as a bare leading
|
||||
/// tag word, so inserting one ahead of `Lock` shifts every existing encoding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Lock `amount` of the holder's balance and emit a cross-zone message
|
||||
/// minting the wrapped token on `target_zone`. The emission fields mirror
|
||||
/// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly.
|
||||
/// minting the wrapped token on `target_zone`.
|
||||
///
|
||||
/// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA.
|
||||
/// `target_program_id` and `target_accounts` are supplied though the guest
|
||||
/// accepts one value for each: `cross_zone::extract_emission` reads them off
|
||||
/// the transaction, decoding every emitter through one shape.
|
||||
///
|
||||
/// `target_zone` is the caller's, so a lock to a zone that will not route it
|
||||
/// escrows and never mints. TODO: bound it source-side.
|
||||
///
|
||||
/// Required accounts (4): config PDA, holder holding (authorized), escrow
|
||||
/// PDA, outbox PDA.
|
||||
Lock {
|
||||
amount: u128,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
outbox_program_id: ProgramId,
|
||||
ordinal: u32,
|
||||
},
|
||||
/// Pins the outbox program and the mint target, written once into a default
|
||||
/// config PDA at genesis. A re-run naming different programs is refused; an
|
||||
/// identical one is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the config PDA.
|
||||
InitConfig {
|
||||
outbox_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
},
|
||||
}
|
||||
|
||||
/// PDA accumulating all locked balance on this zone.
|
||||
@@ -39,6 +57,49 @@ pub const fn escrow_seed() -> PdaSeed {
|
||||
PdaSeed::new(ESCROW_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// PDA holding the outbox program id and the mint target, seeded at genesis so
|
||||
/// the guest can pin both without importing their image ids.
|
||||
#[must_use]
|
||||
pub fn config_account_id(bridge_lock_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&bridge_lock_id, &config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn config_seed() -> PdaSeed {
|
||||
PdaSeed::new(CONFIG_SEED_DOMAIN)
|
||||
}
|
||||
|
||||
/// Encodes the pinned outbox and mint target for the config account's data.
|
||||
#[must_use]
|
||||
pub fn config_bytes(outbox_program_id: ProgramId, target_program_id: ProgramId) -> [u8; 64] {
|
||||
let mut bytes = [0_u8; 64];
|
||||
for (word, chunk) in outbox_program_id
|
||||
.iter()
|
||||
.chain(target_program_id.iter())
|
||||
.zip(bytes.chunks_exact_mut(4))
|
||||
{
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the pinned outbox and mint target from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_config(data: &[u8]) -> Option<(ProgramId, ProgramId)> {
|
||||
if data.len() < 64 {
|
||||
return None;
|
||||
}
|
||||
let mut ids = [0_u32; 16];
|
||||
for (word, chunk) in ids.iter_mut().zip(data[..64].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
let (outbox, target) = ids.split_at(8);
|
||||
Some((
|
||||
outbox.try_into().unwrap_or_else(|_| unreachable!()),
|
||||
target.try_into().unwrap_or_else(|_| unreachable!()),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -48,4 +109,31 @@ mod tests {
|
||||
let id: ProgramId = [4; 8];
|
||||
assert_eq!(escrow_account_id(id), escrow_account_id(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_ids_round_trip() {
|
||||
let outbox: ProgramId = [3; 8];
|
||||
let target: ProgramId = [5; 8];
|
||||
assert_eq!(
|
||||
read_config(&config_bytes(outbox, target)),
|
||||
Some((outbox, target))
|
||||
);
|
||||
}
|
||||
|
||||
/// `extract_emission` decodes `Lock` off peer transactions, so its tag word is
|
||||
/// wire format: a variant inserted ahead of it would silently shift every
|
||||
/// existing encoding.
|
||||
#[test]
|
||||
fn lock_is_the_first_variant() {
|
||||
let lock = Instruction::Lock {
|
||||
amount: 1,
|
||||
target_zone: [7; 32],
|
||||
target_program_id: [1; 8],
|
||||
target_accounts: vec![],
|
||||
payload: vec![],
|
||||
ordinal: 0,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&lock).expect("Lock serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed};
|
||||
use bridge_lock_core::{
|
||||
Instruction, config_account_id, config_bytes, config_seed, escrow_account_id, escrow_seed,
|
||||
read_config,
|
||||
};
|
||||
use cross_zone_outbox_core::Instruction as OutboxInstruction;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{
|
||||
AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput,
|
||||
read_lee_inputs,
|
||||
},
|
||||
};
|
||||
use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT};
|
||||
|
||||
@@ -22,20 +28,74 @@ fn main() {
|
||||
"bridge_lock is only invoked as a top-level user transaction"
|
||||
);
|
||||
|
||||
let Instruction::Lock {
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id,
|
||||
ordinal,
|
||||
} = instruction;
|
||||
match instruction {
|
||||
Instruction::Lock {
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
} => lock(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
amount,
|
||||
target_zone,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
ordinal,
|
||||
),
|
||||
Instruction::InitConfig {
|
||||
outbox_program_id,
|
||||
target_program_id,
|
||||
} => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
outbox_program_id,
|
||||
target_program_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "the emission fields are passed through verbatim"
|
||||
)]
|
||||
fn lock(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
amount: u128,
|
||||
target_zone: [u8; 32],
|
||||
target_program_id: ProgramId,
|
||||
target_accounts: Vec<[u8; 32]>,
|
||||
payload: Vec<u8>,
|
||||
ordinal: u32,
|
||||
) {
|
||||
// pre_states: [config PDA, holder holding (authorized), escrow PDA, outbox PDA].
|
||||
let [config, holder, escrow, outbox] = <[AccountWithMetadata; 4]>::try_from(pre_states)
|
||||
.expect("Lock requires config, holder, escrow, and outbox accounts");
|
||||
|
||||
// Pinned rather than caller-named: chaining elsewhere would debit the escrow
|
||||
// and leave no record of what it was for.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
config_account_id(self_program_id),
|
||||
"first account must be the bridge-lock config PDA"
|
||||
);
|
||||
let (outbox_program_id, pinned_target) = read_config(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an outbox and a mint target");
|
||||
|
||||
// Value conservation: the forwarded payload must mint exactly what is locked.
|
||||
let WrappedInstruction::Mint {
|
||||
recipient,
|
||||
amount: mint_amount,
|
||||
..
|
||||
} = decode_mint(&payload)
|
||||
else {
|
||||
panic!("bridge_lock payload must be a wrapped-token mint");
|
||||
@@ -44,18 +104,27 @@ fn main() {
|
||||
mint_amount, amount,
|
||||
"locked amount must equal the wrapped mint amount"
|
||||
);
|
||||
// Before the debit, not on the destination: nothing releases an escrow, so
|
||||
// an amount the destination will not mint has to fail in the submitter's own
|
||||
// transaction.
|
||||
|
||||
// All before the debit: nothing releases an escrow, so a message the
|
||||
// destination refuses is a burn. `target_zone` is not checkable here, so a
|
||||
// lock aimed at a zone that will not route it still burns.
|
||||
assert_eq!(
|
||||
target_program_id, pinned_target,
|
||||
"bridge_lock only mints through the wrapped token it is pinned to"
|
||||
);
|
||||
assert_eq!(
|
||||
target_accounts,
|
||||
vec![
|
||||
wrapped_token_core::config_account_id(pinned_target).into_value(),
|
||||
wrapped_token_core::holding_account_id(pinned_target, &recipient).into_value(),
|
||||
],
|
||||
"target accounts must be the mint's config and the recipient's holding"
|
||||
);
|
||||
assert!(
|
||||
amount <= MAX_MINT_AMOUNT,
|
||||
"locked amount exceeds what the wrapped token will mint"
|
||||
);
|
||||
|
||||
// pre_states: [holder holding (authorized), escrow PDA, outbox PDA].
|
||||
let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Lock requires holder, escrow, and outbox accounts");
|
||||
|
||||
assert!(holder.is_authorized, "holder must authorize the lock");
|
||||
// The holder holding is bridge_lock-owned, so bridge_lock may debit its native
|
||||
// balance directly (state-machine rule 5). This also pins the transfer to a
|
||||
@@ -68,7 +137,7 @@ fn main() {
|
||||
assert_eq!(
|
||||
escrow.account_id,
|
||||
escrow_account_id(self_program_id),
|
||||
"second account must be the escrow PDA"
|
||||
"third account must be the escrow PDA"
|
||||
);
|
||||
|
||||
// Move the real native balance holder -> escrow. bridge_lock owns both accounts,
|
||||
@@ -106,12 +175,15 @@ fn main() {
|
||||
},
|
||||
);
|
||||
|
||||
let config_post = AccountPostState::new(config.account.clone());
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![holder, escrow, outbox.clone()],
|
||||
vec![config, holder, escrow, outbox.clone()],
|
||||
vec![
|
||||
config_post,
|
||||
holder_post,
|
||||
escrow_post,
|
||||
AccountPostState::new(outbox.account),
|
||||
@@ -121,6 +193,58 @@ fn main() {
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the outbox program and the mint target 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,
|
||||
target_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,
|
||||
config_account_id(self_program_id),
|
||||
"account must be the bridge-lock config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned one must already pin exactly these programs, 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,
|
||||
"bridge-lock config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
config_bytes(outbox_program_id, target_program_id).to_vec(),
|
||||
"bridge-lock config already pins a different outbox or mint target"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = config_bytes(outbox_program_id, target_program_id)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("pinned ids fit in account data");
|
||||
let config_post =
|
||||
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed()));
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config],
|
||||
vec![config_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the
|
||||
/// wrapped-token instruction it carries.
|
||||
fn decode_mint(payload: &[u8]) -> WrappedInstruction {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
@@ -13,6 +13,7 @@ const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/";
|
||||
/// indistinguishable under one domain. Belt and braces, since the image id
|
||||
/// already relocates every PDA in this crate whenever the crate changes.
|
||||
const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/";
|
||||
const SOURCE_MARKER_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneSource/00000/";
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
@@ -84,32 +85,20 @@ pub struct CrossZoneMessage {
|
||||
pub l1_inclusion_witness: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Per-peer delivery routes, plus this inbox's own zone id.
|
||||
/// This inbox's own zone id.
|
||||
///
|
||||
/// It no longer decides who may deliver what. Each target program authorizes its
|
||||
/// own sources against the marker the inbox passes, so the only thing the inbox
|
||||
/// still needs to know is which zone it is, to refuse a message addressed to
|
||||
/// itself.
|
||||
#[derive(
|
||||
Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize,
|
||||
)]
|
||||
pub struct InboxConfig {
|
||||
pub self_zone: ZoneId,
|
||||
/// Which deliveries each peer may make. A peer absent from this map may
|
||||
/// deliver nothing.
|
||||
pub allowed_routes: BTreeMap<ZoneId, Vec<CrossZoneRoute>>,
|
||||
}
|
||||
|
||||
impl InboxConfig {
|
||||
/// Whether `src_zone` may deliver from `src_program_id` to
|
||||
/// `target_program_id`. A peer with no routes may deliver nothing.
|
||||
#[must_use]
|
||||
pub fn permits(
|
||||
&self,
|
||||
src_zone: &ZoneId,
|
||||
src_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
) -> bool {
|
||||
self.allowed_routes
|
||||
.get(src_zone)
|
||||
.is_some_and(|routes| routes_permit(routes, src_program_id, target_program_id))
|
||||
}
|
||||
|
||||
/// Borsh-encoded form stored in the inbox config account.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
@@ -204,25 +193,6 @@ pub enum Instruction {
|
||||
InitConfig(InboxConfig),
|
||||
}
|
||||
|
||||
/// Whether `routes` authorize a delivery from `src_program_id` to
|
||||
/// `target_program_id`.
|
||||
///
|
||||
/// The one place the rule lives. The inbox guest decides with it and the
|
||||
/// sequencer's watcher drops unroutable messages with it, and those two must
|
||||
/// agree: a watcher stricter than the guest loses messages silently, and one
|
||||
/// looser records deliveries the guest will refuse, which production then feeds
|
||||
/// in and gives up on.
|
||||
#[must_use]
|
||||
pub fn routes_permit(
|
||||
routes: &[CrossZoneRoute],
|
||||
src_program_id: ProgramId,
|
||||
target_program_id: ProgramId,
|
||||
) -> bool {
|
||||
routes.iter().any(|route| {
|
||||
route.src_program_id == src_program_id && route.target_program_id == target_program_id
|
||||
})
|
||||
}
|
||||
|
||||
/// Content-addressed replay key for a delivered message.
|
||||
///
|
||||
/// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator.
|
||||
@@ -286,6 +256,48 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed {
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// The account naming who sent a delivery, which the inbox passes at position 0
|
||||
/// of the chained call so the target can authenticate its own sources.
|
||||
///
|
||||
/// Nothing writes or claims it, so the state machine's uninitialized-account rule
|
||||
/// skips it for being unchanged rather than for being default: anyone may send it
|
||||
/// balance, and the inbox and the targets all round-trip it untouched.
|
||||
///
|
||||
/// The address is derivable by anyone, so it is not a secret and not a
|
||||
/// capability. What makes it mean something is that a target checks it only after
|
||||
/// pinning its caller to the inbox, and only the inbox can be that caller.
|
||||
#[must_use]
|
||||
pub fn inbox_source_marker_account_id(
|
||||
inbox_id: ProgramId,
|
||||
src_zone: &ZoneId,
|
||||
src_program_id: ProgramId,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(
|
||||
&inbox_id,
|
||||
&inbox_source_marker_seed(src_zone, src_program_id),
|
||||
)
|
||||
}
|
||||
|
||||
/// Seed of the source marker, exposed so a target can re-derive the address of
|
||||
/// the one source it accepts and compare.
|
||||
#[must_use]
|
||||
pub fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 96];
|
||||
bytes[..32].copy_from_slice(&SOURCE_MARKER_SEED_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(src_zone);
|
||||
for (word, chunk) in src_program_id.iter().zip(bytes[64..].chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.unwrap_or_else(|_| unreachable!());
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lee_core::account::data::DATA_MAX_LENGTH;
|
||||
@@ -296,63 +308,6 @@ mod tests {
|
||||
[b; 32]
|
||||
}
|
||||
|
||||
fn program(n: u32) -> ProgramId {
|
||||
[n; 8]
|
||||
}
|
||||
|
||||
/// The route is the pair. Two entries that are each reasonable on their own,
|
||||
/// a lock program that may mint and a ping emitter that may reach a
|
||||
/// receiver, must not compose into the lock program's target being
|
||||
/// reachable from the ping emitter: that emitter lets its caller choose the
|
||||
/// target, so it would mint with nothing locked behind it.
|
||||
#[test]
|
||||
fn a_route_authorizes_one_pair_and_does_not_compose() {
|
||||
let lock = program(1);
|
||||
let wrapped_token = program(2);
|
||||
let ping_sender = program(3);
|
||||
let ping_receiver = program(4);
|
||||
|
||||
let mut allowed_routes = BTreeMap::new();
|
||||
allowed_routes.insert(
|
||||
zone(9),
|
||||
vec![
|
||||
CrossZoneRoute {
|
||||
src_program_id: lock,
|
||||
target_program_id: wrapped_token,
|
||||
},
|
||||
CrossZoneRoute {
|
||||
src_program_id: ping_sender,
|
||||
target_program_id: ping_receiver,
|
||||
},
|
||||
],
|
||||
);
|
||||
let config = InboxConfig {
|
||||
self_zone: zone(1),
|
||||
allowed_routes,
|
||||
};
|
||||
|
||||
assert!(config.permits(&zone(9), lock, wrapped_token));
|
||||
assert!(config.permits(&zone(9), ping_sender, ping_receiver));
|
||||
|
||||
assert!(
|
||||
!config.permits(&zone(9), ping_sender, wrapped_token),
|
||||
"an emitter whose caller picks the target must not reach the bridge's target"
|
||||
);
|
||||
assert!(
|
||||
!config.permits(&zone(9), lock, ping_receiver),
|
||||
"a route grants its own target, not every target the peer has"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_with_no_routes_may_deliver_nothing() {
|
||||
let config = InboxConfig {
|
||||
self_zone: zone(1),
|
||||
allowed_routes: BTreeMap::new(),
|
||||
};
|
||||
assert!(!config.permits(&zone(9), program(1), program(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_key_is_stable_and_content_addressed() {
|
||||
assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id,
|
||||
inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed,
|
||||
inbox_source_marker_account_id,
|
||||
};
|
||||
use lee_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
@@ -61,10 +62,11 @@ fn dispatch(
|
||||
"l1_inclusion_witness must be None in v1"
|
||||
);
|
||||
|
||||
// pre_states layout: [config, seen_shard, then the target accounts].
|
||||
// pre_states layout: [config, seen_shard, source marker, then the target accounts].
|
||||
let mut accounts = pre_states.into_iter();
|
||||
let config = accounts.next().expect("config account required");
|
||||
let seen = accounts.next().expect("seen shard account required");
|
||||
let marker = accounts.next().expect("source marker account required");
|
||||
let target_accounts: Vec<AccountWithMetadata> = accounts.collect();
|
||||
|
||||
assert_eq!(
|
||||
@@ -77,6 +79,14 @@ fn dispatch(
|
||||
inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id),
|
||||
"Second account must be the seen-shard PDA"
|
||||
);
|
||||
// The one value the chained call carries about where the message came from.
|
||||
// The target re-derives this address from the source it accepts, so binding it
|
||||
// here is what makes a target's own check meaningful.
|
||||
assert_eq!(
|
||||
marker.account_id,
|
||||
inbox_source_marker_account_id(self_program_id, &msg.src_zone, msg.src_program_id),
|
||||
"Third account must be the source marker PDA for this message"
|
||||
);
|
||||
|
||||
let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("inbox config decodes");
|
||||
@@ -85,15 +95,6 @@ fn dispatch(
|
||||
msg.src_zone != cfg.self_zone,
|
||||
"Source zone must not be this zone"
|
||||
);
|
||||
// Checked as a pair. The emitting program is as much a part of the
|
||||
// authorization as the target: an emitter whose caller chooses the target
|
||||
// reaches everything the peer may reach, so a target allowlist on its own
|
||||
// lets any such emitter stand in for every other one.
|
||||
assert!(
|
||||
cfg.permits(&msg.src_zone, msg.src_program_id, msg.target_program_id),
|
||||
"No route from this source program to this target program for this peer"
|
||||
);
|
||||
|
||||
let mut shard =
|
||||
SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes");
|
||||
|
||||
@@ -137,19 +138,23 @@ fn dispatch(
|
||||
.map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!())))
|
||||
.collect();
|
||||
|
||||
// The marker leads, so a target reads its source at a fixed position
|
||||
// without knowing anything about the accounts that follow it.
|
||||
let mut call_pre_states = vec![marker.clone()];
|
||||
call_pre_states.extend(target_accounts.clone());
|
||||
let call = ChainedCall {
|
||||
program_id: msg.target_program_id,
|
||||
pre_states: target_accounts.clone(),
|
||||
pre_states: call_pre_states,
|
||||
instruction_data,
|
||||
pda_seeds: vec![],
|
||||
};
|
||||
(seen_post, vec![call])
|
||||
};
|
||||
|
||||
let mut post_states = vec![unchanged(&config), seen_post];
|
||||
let mut post_states = vec![unchanged(&config), seen_post, unchanged(&marker)];
|
||||
post_states.extend(target_accounts.iter().map(unchanged));
|
||||
|
||||
let mut output_pre_states = vec![config, seen];
|
||||
let mut output_pre_states = vec![config, seen, marker];
|
||||
output_pre_states.extend(target_accounts);
|
||||
|
||||
ProgramOutput::new(
|
||||
@@ -163,8 +168,7 @@ fn dispatch(
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the inbox config (peer + target allowlists) into the config PDA exactly
|
||||
/// once at genesis.
|
||||
/// Writes the inbox config into the config PDA exactly once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
@@ -181,9 +185,8 @@ fn init_config(
|
||||
"account must be the inbox config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned config must already hold exactly these allowlists (the
|
||||
// genesis block is replayed onto seeded state during multi-sequencer
|
||||
// reconstruction), otherwise reject a post-genesis attempt to change them.
|
||||
// init; an already-owned config must already hold exactly this, since genesis
|
||||
// is replayed onto seeded state during multi-sequencer reconstruction.
|
||||
// `new_claimed_if_default` alone would not stop the owning program from
|
||||
// rewriting its own config data on a later call.
|
||||
if config_meta.account != Account::default() {
|
||||
@@ -194,7 +197,7 @@ fn init_config(
|
||||
assert_eq!(
|
||||
config_meta.account.data.clone().into_inner(),
|
||||
config.to_bytes(),
|
||||
"inbox config already initialized with different allowlists"
|
||||
"inbox config already initialized differently"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ use lee_core::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/";
|
||||
/// Versions the seed layout: bump on any change to its field list or offsets,
|
||||
/// so slots under an old layout can never be re-derived. Redundant with the
|
||||
/// image id, which relocates every PDA in this crate whenever the crate changes,
|
||||
/// but the two version different things.
|
||||
const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00001/";
|
||||
|
||||
/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`.
|
||||
pub type ZoneId = [u8; 32];
|
||||
@@ -14,6 +18,10 @@ pub type ZoneId = [u8; 32];
|
||||
pub enum Instruction {
|
||||
/// Records an outbound cross-zone message as a write to a self-owned PDA.
|
||||
///
|
||||
/// The slot is written once: a second `Emit` at the same
|
||||
/// `(emitter, target_zone, ordinal)` fails the transaction rather than
|
||||
/// replacing the record.
|
||||
///
|
||||
/// Required accounts (1):
|
||||
/// - Outbox PDA account
|
||||
Emit {
|
||||
@@ -28,12 +36,21 @@ pub enum Instruction {
|
||||
},
|
||||
}
|
||||
|
||||
/// The message as stored in an outbox PDA. The destination zone's watcher reads
|
||||
/// this from the inscribed block; the source coordinates are filled by the
|
||||
/// watcher, not stored here.
|
||||
/// One emitted message, as stored in its outbox PDA.
|
||||
///
|
||||
/// Carries the slot it occupies as well as the message, so a reader holding the
|
||||
/// bytes knows who wrote them and where without inverting the address, which is
|
||||
/// a hash. The source zone and block coordinates are filled by the destination's
|
||||
/// watcher and are not stored here.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
|
||||
pub struct OutboxRecord {
|
||||
/// The program that called `Emit`, which is the immediate chained caller.
|
||||
/// Cross-zone discovery names the top-level program instead, so joining a
|
||||
/// record against a delivery is only sound while every emitter refuses to be
|
||||
/// called by another program.
|
||||
pub emitter: ProgramId,
|
||||
pub target_zone: ZoneId,
|
||||
pub ordinal: u32,
|
||||
pub target_program_id: ProgramId,
|
||||
pub target_accounts: Vec<[u8; 32]>,
|
||||
pub payload: Vec<u8>,
|
||||
@@ -52,22 +69,34 @@ impl OutboxRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA holding one emitted message, keyed by destination zone and a per-zone
|
||||
/// ordinal.
|
||||
/// PDA holding one emitted message, keyed by the emitting program, the
|
||||
/// destination zone, and a per-emitter per-zone ordinal.
|
||||
///
|
||||
/// `emitter` is the program that called `Emit`, which the guest takes from
|
||||
/// `caller_program_id` rather than from the instruction. Without it in the
|
||||
/// address two programs share a slot and one overwrites the other.
|
||||
#[must_use]
|
||||
pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId {
|
||||
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal))
|
||||
pub fn outbox_pda(
|
||||
outbox_id: ProgramId,
|
||||
emitter: ProgramId,
|
||||
target_zone: &ZoneId,
|
||||
ordinal: u32,
|
||||
) -> AccountId {
|
||||
AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(emitter, target_zone, ordinal))
|
||||
}
|
||||
|
||||
/// Seed of an outbox message PDA, exposed so the guest can claim the account.
|
||||
#[must_use]
|
||||
pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
pub fn outbox_pda_seed(emitter: ProgramId, target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
use risc0_zkvm::sha::{Impl, Sha256 as _};
|
||||
|
||||
let mut bytes = [0_u8; 68];
|
||||
let mut bytes = [0_u8; 100];
|
||||
bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN);
|
||||
bytes[32..64].copy_from_slice(target_zone);
|
||||
bytes[64..].copy_from_slice(&ordinal.to_le_bytes());
|
||||
for (word, chunk) in emitter.iter().zip(bytes[32..64].chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes[64..96].copy_from_slice(target_zone);
|
||||
bytes[96..].copy_from_slice(&ordinal.to_le_bytes());
|
||||
|
||||
let seed: [u8; 32] = Impl::hash_bytes(&bytes)
|
||||
.as_bytes()
|
||||
@@ -80,14 +109,55 @@ pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const OUTBOX: ProgramId = [3; 8];
|
||||
const EMITTER: ProgramId = [4; 8];
|
||||
|
||||
#[test]
|
||||
fn outbox_pda_is_unique_per_zone_and_ordinal() {
|
||||
let id: ProgramId = [3; 8];
|
||||
let zone_a = [1; 32];
|
||||
let zone_b = [2; 32];
|
||||
|
||||
assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1));
|
||||
assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0));
|
||||
assert_eq!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0),
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0)
|
||||
);
|
||||
assert_ne!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0),
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 1)
|
||||
);
|
||||
assert_ne!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_a, 0),
|
||||
outbox_pda(OUTBOX, EMITTER, &zone_b, 0)
|
||||
);
|
||||
}
|
||||
|
||||
/// Two programs emitting to the same zone and ordinal must not share a slot,
|
||||
/// or the second silently overwrites the first.
|
||||
#[test]
|
||||
fn outbox_pda_is_unique_per_emitter() {
|
||||
let zone = [1; 32];
|
||||
let other: ProgramId = [5; 8];
|
||||
|
||||
assert_ne!(
|
||||
outbox_pda(OUTBOX, EMITTER, &zone, 0),
|
||||
outbox_pda(OUTBOX, other, &zone, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_record_round_trips() {
|
||||
let record = OutboxRecord {
|
||||
emitter: EMITTER,
|
||||
target_zone: [1; 32],
|
||||
ordinal: 7,
|
||||
target_program_id: [6; 8],
|
||||
target_accounts: vec![[9; 32]],
|
||||
payload: b"payload".to_vec(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
OutboxRecord::from_bytes(&record.to_bytes()).expect("record decodes"),
|
||||
record
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed};
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
|
||||
@@ -15,10 +15,14 @@ fn main() {
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<Instruction>();
|
||||
|
||||
assert!(
|
||||
caller_program_id.is_some(),
|
||||
"Outbox is only callable through a chain call from a user program"
|
||||
);
|
||||
// The emitter, and the only identity here the state machine verifies: it
|
||||
// checks a guest's claimed caller against the real one. Note this is the
|
||||
// immediate chained caller, not the top-level program that cross-zone
|
||||
// discovery names; the two coincide only while every emitter refuses to be
|
||||
// called by another program, which both do today.
|
||||
let Some(emitter) = caller_program_id else {
|
||||
panic!("Outbox is only callable through a chain call from a user program");
|
||||
};
|
||||
|
||||
let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction {
|
||||
Instruction::Emit {
|
||||
@@ -41,13 +45,32 @@ fn main() {
|
||||
|
||||
assert_eq!(
|
||||
outbox.account_id,
|
||||
outbox_pda(self_program_id, &target_zone, ordinal),
|
||||
"Account must be the outbox PDA for (target_zone, ordinal)"
|
||||
outbox_pda(self_program_id, emitter, &target_zone, ordinal),
|
||||
"Account must be the outbox PDA for (emitter, target_zone, ordinal)"
|
||||
);
|
||||
|
||||
// A slot holds one message for ever. Identity first, so a wrong account that
|
||||
// happens to be free is reported as the wrong account rather than as a used
|
||||
// slot.
|
||||
//
|
||||
// This is the same predicate the state machine already requires of a first
|
||||
// write, so guest and host agree by construction rather than by coincidence.
|
||||
//
|
||||
// It also means a slot can be denied to its intended writer: the ordinal is
|
||||
// caller-chosen in a namespace every user of an emitter shares, and an
|
||||
// emission needs no signature, so anyone can occupy one. A client must pick
|
||||
// an ordinal the chain does not already hold rather than counting from zero.
|
||||
assert_eq!(
|
||||
outbox.account,
|
||||
Account::default(),
|
||||
"Outbox slot already written: one Emit per (emitter, target_zone, ordinal)"
|
||||
);
|
||||
|
||||
let mut post_account = outbox.account.clone();
|
||||
post_account.data = OutboxRecord {
|
||||
emitter,
|
||||
target_zone,
|
||||
ordinal,
|
||||
target_program_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
@@ -56,9 +79,10 @@ fn main() {
|
||||
.try_into()
|
||||
.expect("OutboxRecord fits in account data");
|
||||
|
||||
let post = AccountPostState::new_claimed_if_default(
|
||||
// Unconditional, since the pre-state is provably default by the assert above.
|
||||
let post = AccountPostState::new_claimed(
|
||||
post_account,
|
||||
Claim::Pda(outbox_pda_seed(&target_zone, ordinal)),
|
||||
Claim::Pda(outbox_pda_seed(emitter, &target_zone, ordinal)),
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
|
||||
@@ -8,5 +8,9 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
borsh.workspace = true
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
|
||||
[dev-dependencies]
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
@@ -5,24 +6,79 @@ 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/";
|
||||
const RECEIVER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingReceiverCfg/00000/";
|
||||
/// Raw 32-byte zone (channel) id, matching the inbox's.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
/// Instruction delivered to `ping_receiver` by the inbox: record the payload.
|
||||
/// Instruction to `ping_receiver`.
|
||||
///
|
||||
/// Variants are append-only, for the same reason `SenderInstruction`'s are.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReceiverInstruction {
|
||||
/// Record the payload, delivered by the inbox on behalf of a peer source
|
||||
/// this receiver authorizes.
|
||||
///
|
||||
/// Required accounts (3): the source marker, the receiver config PDA, then
|
||||
/// the record PDA.
|
||||
Record { payload: Vec<u8> },
|
||||
/// Pins the deliverer and the peer sources it may deliver from, written once
|
||||
/// into a default config PDA at genesis. A re-run holding anything different
|
||||
/// is refused; an identical one is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the receiver config PDA.
|
||||
InitConfig(ReceiverConfig),
|
||||
}
|
||||
|
||||
/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`.
|
||||
/// Who may deliver to this receiver, and which peer sources they may deliver from.
|
||||
///
|
||||
/// `ping_receiver` holds nothing worth stealing, so this is not about value. It is
|
||||
/// about the record meaning something: without it any program on any configured
|
||||
/// peer can overwrite the record, and a delivery proves only that some peer sent
|
||||
/// it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
|
||||
pub struct ReceiverConfig {
|
||||
/// The program allowed to call `Record`: the cross-zone inbox.
|
||||
pub deliverer: ProgramId,
|
||||
/// The `(src_zone, src_program_id)` pairs a delivery may originate from.
|
||||
pub sources: Vec<(ZoneId, ProgramId)>,
|
||||
}
|
||||
|
||||
impl ReceiverConfig {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("receiver config serializes")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
borsh::from_slice(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +92,99 @@ 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)
|
||||
}
|
||||
|
||||
/// PDA holding the sources `ping_receiver` accepts a delivery from.
|
||||
#[must_use]
|
||||
pub fn receiver_config_account_id(receiver_id: ProgramId) -> AccountId {
|
||||
AccountId::for_public_pda(&receiver_id, &receiver_config_seed())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn receiver_config_seed() -> PdaSeed {
|
||||
PdaSeed::new(RECEIVER_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);
|
||||
}
|
||||
|
||||
/// `Record` is serialized by the source zone into the emission payload and
|
||||
/// decoded by the destination, so its tag word is wire format.
|
||||
#[test]
|
||||
fn record_is_the_first_variant() {
|
||||
let record = ReceiverInstruction::Record { payload: vec![] };
|
||||
let words = risc0_zkvm::serde::to_vec(&record).expect("Record serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_receiver_config_does_not_decode() {
|
||||
assert_eq!(ReceiverConfig::from_bytes(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receiver_config_round_trips() {
|
||||
let config = ReceiverConfig {
|
||||
deliverer: [1; 8],
|
||||
sources: vec![([7; 32], [9; 8])],
|
||||
};
|
||||
assert_eq!(ReceiverConfig::from_bytes(&config.to_bytes()), Some(config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_id_round_trips() {
|
||||
let outbox: ProgramId = [9; 8];
|
||||
assert_eq!(read_outbox(&outbox_bytes(outbox)), Some(outbox));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
cross_zone_inbox_core.workspace = true
|
||||
lee_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
use cross_zone_inbox_core::inbox_source_marker_account_id;
|
||||
use lee_core::{
|
||||
account::AccountWithMetadata,
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, Claim, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use ping_core::{
|
||||
ReceiverConfig, ReceiverInstruction, ping_record_pda, ping_record_seed,
|
||||
receiver_config_account_id, receiver_config_seed,
|
||||
};
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed};
|
||||
|
||||
fn main() {
|
||||
let (
|
||||
@@ -15,21 +19,61 @@ fn main() {
|
||||
instruction_words,
|
||||
) = read_lee_inputs::<ReceiverInstruction>();
|
||||
|
||||
match instruction {
|
||||
ReceiverInstruction::Record { payload } => record(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
payload,
|
||||
),
|
||||
ReceiverInstruction::InitConfig(config) => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
&config,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(
|
||||
self_program_id: ProgramId,
|
||||
caller_program_id: Option<ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
payload: Vec<u8>,
|
||||
) {
|
||||
// pre_states: [source marker, config PDA, record PDA].
|
||||
let [marker, config, record] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Record requires the source marker, config, and record accounts");
|
||||
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
receiver_config_account_id(self_program_id),
|
||||
"Second account must be the receiver config PDA"
|
||||
);
|
||||
let cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds a receiver config");
|
||||
assert_eq!(
|
||||
caller_program_id,
|
||||
Some(cfg.deliverer),
|
||||
"Record is only callable by the authorized deliverer (the cross-zone inbox)"
|
||||
);
|
||||
// Which peer sent it is this program's own business. Without this the record
|
||||
// says only that some program on some configured peer wrote it.
|
||||
assert!(
|
||||
caller_program_id.is_some(),
|
||||
"ping_receiver is only callable through a chained call"
|
||||
cfg.sources.iter().any(|(src_zone, src_program_id)| {
|
||||
marker.account_id
|
||||
== inbox_source_marker_account_id(cfg.deliverer, src_zone, *src_program_id)
|
||||
}),
|
||||
"Record is only callable for a peer source this receiver authorizes"
|
||||
);
|
||||
|
||||
let payload = match instruction {
|
||||
ReceiverInstruction::Record { payload } => payload,
|
||||
};
|
||||
|
||||
let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("Record requires exactly 1 account");
|
||||
assert_eq!(
|
||||
record.account_id,
|
||||
ping_record_pda(self_program_id),
|
||||
"Account must be the ping record PDA"
|
||||
"Third account must be the ping record PDA"
|
||||
);
|
||||
|
||||
let mut post_account = record.account.clone();
|
||||
@@ -41,8 +85,70 @@ fn main() {
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![record],
|
||||
vec![post],
|
||||
vec![marker.clone(), config.clone(), record],
|
||||
vec![
|
||||
AccountPostState::new(marker.account),
|
||||
AccountPostState::new(config.account),
|
||||
post,
|
||||
],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the deliverer and the authorized peer sources 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>,
|
||||
config_value: &ReceiverConfig,
|
||||
) {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
"InitConfig is a top-level genesis transaction"
|
||||
);
|
||||
|
||||
// pre_states: [config PDA].
|
||||
let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states)
|
||||
.expect("InitConfig requires the config account");
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
receiver_config_account_id(self_program_id),
|
||||
"account must be the receiver config PDA"
|
||||
);
|
||||
// Init-once, idempotent under genesis replay: a `default` config is a first
|
||||
// init; an already-owned one must already hold exactly this, 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,
|
||||
"receiver config PDA is owned by another program"
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
config_value.to_bytes(),
|
||||
"receiver config already initialized differently"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = config_value
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("receiver config fits in account data");
|
||||
let config_post = AccountPostState::new_claimed_if_default(
|
||||
config_account,
|
||||
Claim::Pda(receiver_config_seed()),
|
||||
);
|
||||
|
||||
ProgramOutput::new(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config],
|
||||
vec![config_post],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
cross_zone_inbox_core.workspace = true
|
||||
lee_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
|
||||
@@ -8,6 +8,7 @@ license = { workspace = true }
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
borsh.workspace = true
|
||||
lee_core.workspace = true
|
||||
serde = { workspace = true, features = ["alloc"] }
|
||||
risc0-zkvm.workspace = true
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces
|
||||
//! this by reading the authorized minter from a genesis-seeded config account.
|
||||
|
||||
use borsh::{BorshDeserialize, BorshSerialize};
|
||||
use lee_core::{
|
||||
account::AccountId,
|
||||
program::{PdaSeed, ProgramId},
|
||||
@@ -22,21 +23,49 @@ pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF;
|
||||
|
||||
const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/";
|
||||
const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000";
|
||||
/// Raw 32-byte zone (channel) id, matching the inbox's.
|
||||
pub type ZoneId = [u8; 32];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Instruction {
|
||||
/// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by
|
||||
/// the cross-zone inbox.
|
||||
/// the cross-zone inbox, and only for a peer source this token authorizes.
|
||||
///
|
||||
/// Required accounts (2): the wrapped-token config PDA, then the recipient's
|
||||
/// holding PDA.
|
||||
/// Required accounts (3): the source marker, the wrapped-token config PDA,
|
||||
/// then the recipient's holding PDA.
|
||||
Mint { recipient: [u8; 32], amount: u128 },
|
||||
/// Pins `minter` (the cross-zone inbox) as the authorized minter, written once
|
||||
/// into a default config PDA at genesis. The guest refuses a non-default
|
||||
/// pre-state, so it cannot be re-run to hijack the minter.
|
||||
/// Pins the minter and the peer sources it may mint for, written once into a
|
||||
/// default config PDA at genesis. A re-run holding anything different is
|
||||
/// refused; an identical one is a no-op, which is what genesis replay does.
|
||||
///
|
||||
/// Required accounts (1): the wrapped-token config PDA.
|
||||
InitConfig { minter: ProgramId },
|
||||
InitConfig(WrappedTokenConfig),
|
||||
}
|
||||
|
||||
/// Who may mint, and which peer sources they may mint for.
|
||||
///
|
||||
/// The source list is what makes this token authorize its own inbound value
|
||||
/// rather than trusting a central route table to have done it. Borsh because the
|
||||
/// list is variable length.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
|
||||
pub struct WrappedTokenConfig {
|
||||
/// The program allowed to call `Mint`: the cross-zone inbox.
|
||||
pub minter: ProgramId,
|
||||
/// The `(src_zone, src_program_id)` pairs a mint may originate from. Empty on
|
||||
/// a zone with no peers, which authorizes nothing.
|
||||
pub sources: Vec<(ZoneId, ProgramId)>,
|
||||
}
|
||||
|
||||
impl WrappedTokenConfig {
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
borsh::to_vec(self).expect("wrapped-token config serializes")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
borsh::from_slice(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at
|
||||
@@ -71,29 +100,6 @@ pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed {
|
||||
PdaSeed::new(seed)
|
||||
}
|
||||
|
||||
/// Encodes the authorized minter program id for the config account's data.
|
||||
#[must_use]
|
||||
pub fn minter_bytes(minter: ProgramId) -> [u8; 32] {
|
||||
let mut bytes = [0_u8; 32];
|
||||
for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) {
|
||||
chunk.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Decodes the authorized minter program id from the config account's data.
|
||||
#[must_use]
|
||||
pub fn read_minter(data: &[u8]) -> Option<ProgramId> {
|
||||
if data.len() < 32 {
|
||||
return None;
|
||||
}
|
||||
let mut minter = [0_u32; 8];
|
||||
for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) {
|
||||
*word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()));
|
||||
}
|
||||
Some(minter)
|
||||
}
|
||||
|
||||
/// Reads a wrapped-token balance from account data; empty data is a zero balance.
|
||||
#[must_use]
|
||||
pub fn read_balance(data: &[u8]) -> u128 {
|
||||
@@ -113,9 +119,34 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn minter_round_trips() {
|
||||
let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
assert_eq!(read_minter(&minter_bytes(minter)), Some(minter));
|
||||
fn config_round_trips() {
|
||||
let config = WrappedTokenConfig {
|
||||
minter: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
sources: vec![([7; 32], [9; 8]), ([8; 32], [4; 8])],
|
||||
};
|
||||
assert_eq!(
|
||||
WrappedTokenConfig::from_bytes(&config.to_bytes()),
|
||||
Some(config)
|
||||
);
|
||||
}
|
||||
|
||||
/// An unclaimed config reads as empty, which must not decode to a config that
|
||||
/// authorizes anything.
|
||||
#[test]
|
||||
fn an_empty_config_does_not_decode() {
|
||||
assert_eq!(WrappedTokenConfig::from_bytes(&[]), None);
|
||||
}
|
||||
|
||||
/// The peer's `bridge_lock` serializes `Mint` into the emission payload, so
|
||||
/// its tag word is wire format.
|
||||
#[test]
|
||||
fn mint_is_the_first_variant() {
|
||||
let mint = Instruction::Mint {
|
||||
recipient: [3; 32],
|
||||
amount: 1,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&mint).expect("Mint serializes");
|
||||
assert_eq!(words[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use cross_zone_inbox_core::inbox_source_marker_account_id;
|
||||
use lee_core::{
|
||||
account::{Account, AccountWithMetadata},
|
||||
program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs},
|
||||
};
|
||||
use wrapped_token_core::{
|
||||
Instruction, MAX_MINT_AMOUNT, balance_bytes, config_account_id, config_seed,
|
||||
holding_account_id, holding_seed, minter_bytes, read_balance, read_minter,
|
||||
Instruction, MAX_MINT_AMOUNT, WrappedTokenConfig, balance_bytes, config_account_id,
|
||||
config_seed, holding_account_id, holding_seed, read_balance,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -27,12 +28,12 @@ fn main() {
|
||||
recipient,
|
||||
amount,
|
||||
),
|
||||
Instruction::InitConfig { minter } => init_config(
|
||||
Instruction::InitConfig(config) => init_config(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
pre_states,
|
||||
instruction_words,
|
||||
minter,
|
||||
&config,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -45,29 +46,40 @@ fn mint(
|
||||
recipient: [u8; 32],
|
||||
amount: u128,
|
||||
) {
|
||||
// pre_states: [config PDA, recipient holding PDA].
|
||||
let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states)
|
||||
.expect("Mint requires the config and recipient holding accounts");
|
||||
// pre_states: [source marker, config PDA, recipient holding PDA].
|
||||
let [marker, config, holding] = <[AccountWithMetadata; 3]>::try_from(pre_states)
|
||||
.expect("Mint requires the source marker, config, and recipient holding accounts");
|
||||
|
||||
// The config PDA is genesis-seeded with the authorized minter (the cross-zone
|
||||
// inbox). Pin the caller to it, since the guest cannot import the inbox id.
|
||||
assert_eq!(
|
||||
config.account_id,
|
||||
config_account_id(self_program_id),
|
||||
"first account must be the wrapped-token config PDA"
|
||||
"second account must be the wrapped-token config PDA"
|
||||
);
|
||||
let minter = read_minter(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds an authorized minter id");
|
||||
let cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner())
|
||||
.expect("config account holds a wrapped-token config");
|
||||
assert_eq!(
|
||||
caller_program_id,
|
||||
Some(minter),
|
||||
Some(cfg.minter),
|
||||
"Mint is only callable by the authorized minter (the cross-zone inbox)"
|
||||
);
|
||||
// The inbox vouches only that the message arrived; which peer sent it is this
|
||||
// token's own business, and unbacked value is what gets minted if it takes
|
||||
// anyone's word for it. The marker's address is the source, so re-deriving it
|
||||
// from an authorized pair is the whole check.
|
||||
assert!(
|
||||
cfg.sources.iter().any(|(src_zone, src_program_id)| {
|
||||
marker.account_id
|
||||
== inbox_source_marker_account_id(cfg.minter, src_zone, *src_program_id)
|
||||
}),
|
||||
"Mint is only callable for a peer source this token authorizes"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
holding.account_id,
|
||||
holding_account_id(self_program_id, &recipient),
|
||||
"second account must be the recipient holding PDA"
|
||||
"third account must be the recipient holding PDA"
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -93,19 +105,24 @@ fn mint(
|
||||
self_program_id,
|
||||
caller_program_id,
|
||||
instruction_words,
|
||||
vec![config, holding],
|
||||
vec![config_post, holding_post],
|
||||
vec![marker.clone(), config, holding],
|
||||
vec![
|
||||
AccountPostState::new(marker.account),
|
||||
config_post,
|
||||
holding_post,
|
||||
],
|
||||
)
|
||||
.write();
|
||||
}
|
||||
|
||||
/// Writes the authorized minter into the config PDA exactly once at genesis.
|
||||
/// Writes the minter and the authorized peer sources into the config PDA exactly
|
||||
/// once at genesis.
|
||||
fn init_config(
|
||||
self_program_id: lee_core::program::ProgramId,
|
||||
caller_program_id: Option<lee_core::program::ProgramId>,
|
||||
pre_states: Vec<AccountWithMetadata>,
|
||||
instruction_words: Vec<u32>,
|
||||
minter: lee_core::program::ProgramId,
|
||||
config_value: &WrappedTokenConfig,
|
||||
) {
|
||||
assert!(
|
||||
caller_program_id.is_none(),
|
||||
@@ -133,16 +150,16 @@ fn init_config(
|
||||
);
|
||||
assert_eq!(
|
||||
config.account.data.clone().into_inner(),
|
||||
minter_bytes(minter).to_vec(),
|
||||
"wrapped-token config already initialized with a different minter"
|
||||
config_value.to_bytes(),
|
||||
"wrapped-token config already initialized differently"
|
||||
);
|
||||
}
|
||||
|
||||
let mut config_account = config.account.clone();
|
||||
config_account.data = minter_bytes(minter)
|
||||
.to_vec()
|
||||
config_account.data = config_value
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("minter id fits in account data");
|
||||
.expect("wrapped-token config fits in account data");
|
||||
let config_post =
|
||||
AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed()));
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use common::{HashType, block::Block, transaction::LeeTransaction};
|
||||
use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission};
|
||||
use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit};
|
||||
use cross_zone::{
|
||||
EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program,
|
||||
};
|
||||
use cross_zone_inbox_core::message_key;
|
||||
use futures::{Stream, StreamExt as _};
|
||||
use lee::{GENESIS_BLOCK_ID, PublicKey};
|
||||
use log::{debug, error, warn};
|
||||
@@ -36,7 +38,6 @@ const STUCK_SLOT_ALERT_PASSES: u32 = 20;
|
||||
struct PeerContext {
|
||||
peer_zone: [u8; 32],
|
||||
self_zone: [u8; 32],
|
||||
allowed_routes: Vec<CrossZoneRoute>,
|
||||
expected_pubkey: Option<PublicKey>,
|
||||
}
|
||||
|
||||
@@ -280,7 +281,6 @@ pub fn spawn_watchers(
|
||||
PeerContext {
|
||||
peer_zone: peer.channel_id,
|
||||
self_zone,
|
||||
allowed_routes: peer.allowed_routes,
|
||||
expected_pubkey,
|
||||
},
|
||||
poll_interval,
|
||||
@@ -556,7 +556,6 @@ fn record_block_deliveries(
|
||||
) -> bool {
|
||||
let peer_zone = peer.peer_zone;
|
||||
let self_zone = peer.self_zone;
|
||||
let allowed_routes = peer.allowed_routes.as_slice();
|
||||
// Collected and written once. The pending list is a single value, so a write
|
||||
// per delivery would rewrite the whole list once per message, which is
|
||||
// quadratic in a peer block that carries many of them, on a task holding the
|
||||
@@ -574,16 +573,15 @@ fn record_block_deliveries(
|
||||
if emission.target_zone != self_zone {
|
||||
continue;
|
||||
}
|
||||
// Mirrors the inbox guest, which is the authority. Dropping here keeps
|
||||
// an unroutable message from becoming a record that production would
|
||||
// feed in and give up on three blocks later.
|
||||
if !routes_permit(
|
||||
allowed_routes,
|
||||
message.program_id,
|
||||
emission.target_program_id,
|
||||
) {
|
||||
// Targets authorize their own sources now, so this is not authorization,
|
||||
// it is hygiene: a delivery the zone will certainly refuse still costs a
|
||||
// pending-list slot and three execution attempts before it is dead
|
||||
// lettered. Kept host-side only, never in `extract_emission` or the
|
||||
// verifier's re-derivation, where a check that depends on this build would
|
||||
// make the two disagree and halt ingestion.
|
||||
if is_sequencer_only_program(emission.target_program_id) {
|
||||
warn!(
|
||||
"Watcher dropping message from peer {}: no route from that source program to that target",
|
||||
"Watcher dropping message from peer {}: a peer may not dispatch into a sequencer-only program",
|
||||
hex::encode(peer_zone)
|
||||
);
|
||||
continue;
|
||||
@@ -667,7 +665,7 @@ mod tests {
|
||||
};
|
||||
use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription};
|
||||
use logos_blockchain_zone_sdk::ZoneBlock;
|
||||
use ping_core::{SenderInstruction, ping_record_pda};
|
||||
use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id};
|
||||
use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -680,10 +678,6 @@ mod tests {
|
||||
PeerContext {
|
||||
peer_zone: PEER_ZONE,
|
||||
self_zone: SELF_ZONE,
|
||||
allowed_routes: vec![CrossZoneRoute {
|
||||
src_program_id: programs::ping_sender().id(),
|
||||
target_program_id: programs::ping_receiver().id(),
|
||||
}],
|
||||
expected_pubkey: None,
|
||||
}
|
||||
}
|
||||
@@ -707,10 +701,12 @@ 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()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload: b"hi".to_vec(),
|
||||
ordinal: 0,
|
||||
};
|
||||
@@ -1080,13 +1076,49 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_delivery_with_no_route_is_never_recorded() {
|
||||
// The peer is routed to ping_receiver only. A bridging zone would also
|
||||
// route its lock program to wrapped_token, and `ping_sender` lets its
|
||||
// caller name wrapped_token as the target, so without the pair check
|
||||
// this emission would be recorded and delivered, minting with nothing
|
||||
// locked behind it. The guest rejects it too; dropping here keeps it
|
||||
// from becoming a record production feeds in and gives up on.
|
||||
async fn a_delivery_into_a_sequencer_only_program_is_never_recorded() {
|
||||
// Targets authorize their own sources, so the watcher no longer decides
|
||||
// who may reach what. It still refuses to queue a delivery the zone will
|
||||
// certainly refuse: the inbox is injected by this node alone, so a peer
|
||||
// naming it as a target is junk that would cost a pending slot and three
|
||||
// execution attempts.
|
||||
let (_dir, dbio) = store();
|
||||
let mut cursor = None;
|
||||
let mut tip = None;
|
||||
|
||||
let outcome = consume_peer_stream(
|
||||
stream::iter(vec![peer_block_msg_to(
|
||||
1,
|
||||
0,
|
||||
programs::cross_zone_inbox().id(),
|
||||
)]),
|
||||
&peer_context(),
|
||||
&dbio,
|
||||
&mut cursor,
|
||||
&mut tip,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PassOutcome::Drained,
|
||||
"a message the watcher drops is not a failure"
|
||||
);
|
||||
assert!(
|
||||
recorded_keys(&dbio).is_empty(),
|
||||
"a message aimed at a sequencer-only program must not be recorded"
|
||||
);
|
||||
assert_eq!(
|
||||
get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(),
|
||||
Some(Slot::from(0)),
|
||||
"the slot was fully read, so the floor still advances"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_delivery_to_an_unrelated_target_is_still_recorded() {
|
||||
// The watcher is not the authorization point any more. A target it knows
|
||||
// nothing about is recorded and delivered, and that target decides.
|
||||
let (_dir, dbio) = store();
|
||||
let mut cursor = None;
|
||||
let mut tip = None;
|
||||
@@ -1104,19 +1136,11 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, PassOutcome::Drained);
|
||||
assert_eq!(
|
||||
outcome,
|
||||
PassOutcome::Drained,
|
||||
"an unroutable message is not a failure"
|
||||
);
|
||||
assert!(
|
||||
recorded_keys(&dbio).is_empty(),
|
||||
"a message with no route must not be recorded"
|
||||
);
|
||||
assert_eq!(
|
||||
get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(),
|
||||
Some(Slot::from(0)),
|
||||
"the slot was fully read, so the floor still advances"
|
||||
recorded_keys(&dbio).len(),
|
||||
1,
|
||||
"the watcher records it and lets the target refuse it"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1523,16 +1523,24 @@ 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.
|
||||
let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx());
|
||||
let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| {
|
||||
// reconstructs them by replaying the genesis block. The wrapped-token minter and
|
||||
// both emitters' pins are initialized on every zone: all three are builtins with
|
||||
// a user-callable InitConfig, so a config PDA left default is claimable by the
|
||||
// first initializer, hijacking the minter or repointing an 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(
|
||||
config.cross_zone.as_ref(),
|
||||
));
|
||||
let ping_sender_config_tx = std::iter::once(cross_zone::build_ping_sender_init_config_tx());
|
||||
let ping_receiver_config_tx = std::iter::once(cross_zone::build_ping_receiver_init_config_tx(
|
||||
config.cross_zone.as_ref(),
|
||||
));
|
||||
let bridge_lock_config_tx = std::iter::once(cross_zone::build_bridge_lock_init_config_tx());
|
||||
let inbox_config_tx = config.cross_zone.as_ref().map(|_| {
|
||||
let self_zone = *config.bedrock_config.channel_id.as_ref();
|
||||
cross_zone::build_inbox_init_config_tx(self_zone, cross_zone)
|
||||
cross_zone::build_inbox_init_config_tx(self_zone)
|
||||
});
|
||||
let supply_txs = config.genesis.iter().filter_map(|action| match action {
|
||||
GenesisAction::SupplyAccount {
|
||||
@@ -1550,6 +1558,9 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec<LeeTrans
|
||||
});
|
||||
|
||||
let genesis_txs = wrapped_token_config_tx
|
||||
.chain(ping_sender_config_tx)
|
||||
.chain(ping_receiver_config_tx)
|
||||
.chain(bridge_lock_config_tx)
|
||||
.chain(inbox_config_tx)
|
||||
.chain(supply_txs)
|
||||
.chain(std::iter::once(clock_invocation(0)))
|
||||
|
||||
@@ -23,7 +23,7 @@ use logos_blockchain_core::{
|
||||
use logos_blockchain_key_management_system_service::keys::ZkPublicKey;
|
||||
use logos_blockchain_zone_sdk::sequencer::DepositInfo;
|
||||
use mempool::MemPoolHandle;
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda};
|
||||
use ping_core::{ReceiverInstruction, ping_record_pda, receiver_config_account_id};
|
||||
use storage::sequencer::sequencer_cells::{
|
||||
DispatchOrigin, PendingCrossZoneDispatchRecord, PendingDepositEventRecord,
|
||||
};
|
||||
@@ -216,7 +216,10 @@ fn dispatch_tx(src_block_id: u64, payload: Vec<u8>) -> LeeTransaction {
|
||||
src_program_id: programs::ping_sender().id(),
|
||||
},
|
||||
receiver_id,
|
||||
&[ping_record_pda(receiver_id).into_value()],
|
||||
&[
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
))
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -23,3 +23,4 @@ serde = { workspace = true, features = ["derive"] }
|
||||
axum.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
rand.workspace = true
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
clippy::unused_async,
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::infinite_loop,
|
||||
reason = "Demo binary: stdout banner is the deliverable; ordinal/elapsed arithmetic is \
|
||||
bounded at chat scale; the per-zone scanners and finality poller are daemon \
|
||||
reason = "Demo binary: stdout banner is the deliverable; elapsed arithmetic is bounded at chat \
|
||||
scale; the per-zone scanners and finality poller are daemon \
|
||||
loops that run for the process lifetime; axum handlers must be `async` and take \
|
||||
their extractors (State/Json/Query) by value to satisfy the framework's bounds."
|
||||
)]
|
||||
@@ -57,11 +57,14 @@ use common::{block::BedrockStatus, transaction::LeeTransaction};
|
||||
use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId};
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use lee::{
|
||||
ProgramId, PublicTransaction,
|
||||
Account, ProgramId, PublicTransaction,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use log::warn;
|
||||
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
|
||||
use log::{info, warn};
|
||||
use ping_core::{
|
||||
ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id,
|
||||
sender_config_account_id,
|
||||
};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use test_fixtures::{
|
||||
@@ -71,6 +74,10 @@ use test_fixtures::{
|
||||
|
||||
const HTTP_PORT: u16 = 8088;
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// Ordinals probed for a free outbox slot before giving up.
|
||||
const ORDINAL_PROBE_LIMIT: u32 = 1_024;
|
||||
/// Transient RPC failures tolerated per probed slot.
|
||||
const RPC_RETRY_LIMIT: u32 = 5;
|
||||
|
||||
/// One chat message tracked through its cross-zone pipeline. Displayed in the
|
||||
/// receiving zone's column; the stage fields drive the on-page timeline.
|
||||
@@ -127,8 +134,12 @@ struct ZoneRuntime {
|
||||
/// The peer zone's channel id; the target of sends from this zone.
|
||||
other_zone: ZoneId,
|
||||
client: SequencerClient,
|
||||
/// Monotonic outbox ordinal per (this zone -> peer); each send must use a
|
||||
/// fresh value because the outbox PDA is claimed only when default.
|
||||
/// Next outbox ordinal to try for (this zone -> peer).
|
||||
///
|
||||
/// An outbox slot is written once and the ordinal names a slot in a space
|
||||
/// every user of `ping_sender` shares, so a fixed starting point collides
|
||||
/// with whatever the chain already holds. Seeded from a free slot found by
|
||||
/// [`next_free_ordinal`], then incremented per send.
|
||||
ordinal: AtomicU32,
|
||||
}
|
||||
|
||||
@@ -304,16 +315,26 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
|
||||
let client_a = sequencer_client(seq_a.addr())?;
|
||||
let client_b = sequencer_client(seq_b.addr())?;
|
||||
let ordinal_a = next_free_ordinal(&client_a, &zone_b)
|
||||
.await
|
||||
.context("Failed to find a free outbox ordinal for zone A")?;
|
||||
let ordinal_b = next_free_ordinal(&client_b, &zone_a)
|
||||
.await
|
||||
.context("Failed to find a free outbox ordinal for zone B")?;
|
||||
info!("Outbox ordinals start at A={ordinal_a} B={ordinal_b}");
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
zone_a: ZoneRuntime {
|
||||
other_zone: zone_b,
|
||||
client: sequencer_client(seq_a.addr())?,
|
||||
ordinal: AtomicU32::new(0),
|
||||
client: client_a,
|
||||
ordinal: AtomicU32::new(ordinal_a),
|
||||
},
|
||||
zone_b: ZoneRuntime {
|
||||
other_zone: zone_a,
|
||||
client: sequencer_client(seq_b.addr())?,
|
||||
ordinal: AtomicU32::new(0),
|
||||
client: client_b,
|
||||
ordinal: AtomicU32::new(ordinal_b),
|
||||
},
|
||||
next_id: AtomicU64::new(1),
|
||||
messages: Mutex::new(Vec::new()),
|
||||
@@ -365,6 +386,55 @@ fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// A free outbox slot for this zone's `ping_sender` to start counting from: the
|
||||
/// first unwritten one at or after a random ordinal.
|
||||
///
|
||||
/// An outbox slot is written once, so a send into an occupied one fails at block
|
||||
/// production, and `send_transaction` checks a transaction only statelessly, so
|
||||
/// nothing tells the sender. The predicate here is the one the guest asserts,
|
||||
/// asked before submitting instead of after.
|
||||
///
|
||||
/// Random rather than zero because the ordinal space is shared by every user of
|
||||
/// `ping_sender` and a slot costs one unsigned transaction to occupy, so any
|
||||
/// fixed starting point can be squatted. On a chain this process owns, which is
|
||||
/// what `just cross-zone-chat` boots, nothing is occupied and this returns on
|
||||
/// its first try; it earns its keep against a chain the tool did not create.
|
||||
///
|
||||
/// It only places the first send. Later ordinals come from incrementing, so a
|
||||
/// slot taken after this returns still collides, at a probability of the
|
||||
/// occupied count over 2^32.
|
||||
async fn next_free_ordinal(client: &SequencerClient, target_zone: &ZoneId) -> Result<u32> {
|
||||
let outbox_id = programs::cross_zone_outbox().id();
|
||||
let emitter = programs::ping_sender().id();
|
||||
let start: u32 = rand::random();
|
||||
|
||||
for offset in 0..ORDINAL_PROBE_LIMIT {
|
||||
let ordinal = start.wrapping_add(offset);
|
||||
let slot = outbox_pda(outbox_id, emitter, target_zone, ordinal);
|
||||
// Retried rather than propagated: by here the run has already paid for a
|
||||
// Bedrock bring-up and two sequencer boots, and every other RPC caller
|
||||
// in this tool rides out a transient error rather than ending the run.
|
||||
let mut attempt = 0_u32;
|
||||
let account = loop {
|
||||
match client.get_account(slot).await {
|
||||
Ok(account) => break account,
|
||||
Err(err) if attempt < RPC_RETRY_LIMIT => {
|
||||
attempt += 1;
|
||||
warn!("Outbox probe failed, retrying ({attempt}/{RPC_RETRY_LIMIT}): {err}");
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).context("Failed to read an outbox slot while probing");
|
||||
}
|
||||
}
|
||||
};
|
||||
if account == Account::default() {
|
||||
return Ok(ordinal);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("No free outbox ordinal in {ORDINAL_PROBE_LIMIT} tried from {start}")
|
||||
}
|
||||
|
||||
/// Scans one zone's new blocks. A `ping_sender` tx marks the message's source
|
||||
/// block (its outbound leg); an inbox dispatch marks delivery on this zone.
|
||||
/// Runs forever; transient RPC errors are logged and retried.
|
||||
@@ -455,7 +525,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)
|
||||
}
|
||||
|
||||
@@ -470,7 +542,9 @@ fn decode_payload(payload: &[u8]) -> Option<String> {
|
||||
.collect();
|
||||
let instruction: ReceiverInstruction =
|
||||
risc0_zkvm::serde::from_slice::<ReceiverInstruction, u32>(&words).ok()?;
|
||||
let ReceiverInstruction::Record { payload: bytes } = instruction;
|
||||
let ReceiverInstruction::Record { payload: bytes } = instruction else {
|
||||
return None;
|
||||
};
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
@@ -487,18 +561,21 @@ 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()],
|
||||
target_accounts: vec![
|
||||
receiver_config_account_id(receiver_id).into_value(),
|
||||
ping_record_pda(receiver_id).into_value(),
|
||||
],
|
||||
payload,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let outbox_account = outbox_pda(outbox_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