mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-06-29 10:29:32 +00:00
test(cross-zone): add lock-on-A mint-on-B bridge round trip
This commit is contained in:
parent
2c387899c4
commit
4fd465b529
@ -25,6 +25,8 @@ faucet_core.workspace = true
|
||||
bridge_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
cross_zone_outbox_core.workspace = true
|
||||
bridge_lock_core.workspace = true
|
||||
wrapped_token_core.workspace = true
|
||||
risc0-zkvm.workspace = true
|
||||
indexer_service_rpc = { workspace = true, features = ["client"] }
|
||||
sequencer_service_rpc = { workspace = true, features = ["client"] }
|
||||
|
||||
184
integration_tests/tests/cross_zone_bridge.rs
Normal file
184
integration_tests/tests/cross_zone_bridge.rs
Normal file
@ -0,0 +1,184 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
clippy::arithmetic_side_effects,
|
||||
reason = "We don't care about these in tests"
|
||||
)]
|
||||
|
||||
//! Demo 2: a wrapped-token bridge over the cross-zone spine. A holder locks part
|
||||
//! of their bridgeable balance on zone A; the watcher carries the emitted mint to
|
||||
//! zone B, where the indexer re-derives and verifies it (Option B) before the
|
||||
//! 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.
|
||||
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use common::transaction::LeeTransaction;
|
||||
use cross_zone_outbox_core::outbox_pda;
|
||||
use integration_tests::{
|
||||
config::{self, SequencerPartialConfig},
|
||||
indexer_client::IndexerClient,
|
||||
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
|
||||
};
|
||||
use lee::{
|
||||
AccountId, PrivateKey, PublicKey, PublicTransaction,
|
||||
program::Program,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
};
|
||||
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
|
||||
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
|
||||
use tokio::test;
|
||||
|
||||
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const INITIAL_BALANCE: u128 = 100;
|
||||
const LOCK_AMOUNT: u128 = 30;
|
||||
const RECIPIENT: [u8; 32] = [9; 32];
|
||||
|
||||
#[test]
|
||||
async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> {
|
||||
// Declared first so it outlives both zones (drops run in reverse order).
|
||||
let (_bedrock, bedrock_addr) = setup_bedrock_node()
|
||||
.await
|
||||
.context("Failed to set up shared Bedrock node")?;
|
||||
|
||||
let partial = SequencerPartialConfig::default();
|
||||
let channel_a = config::bedrock_channel_id();
|
||||
let channel_b = config::bedrock_channel_id_b();
|
||||
let zone_b: [u8; 32] = *channel_b.as_ref();
|
||||
|
||||
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
|
||||
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
|
||||
|
||||
let wrapped_token_id = Program::wrapped_token().id();
|
||||
let cross_zone = CrossZoneConfig {
|
||||
peers: vec![CrossZonePeer {
|
||||
channel_id: *channel_a.as_ref(),
|
||||
allowed_targets: vec![wrapped_token_id],
|
||||
}],
|
||||
};
|
||||
|
||||
// Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its
|
||||
// sequencer and the verifier on its indexer.
|
||||
let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
|
||||
holder: holder_id,
|
||||
amount: INITIAL_BALANCE,
|
||||
}];
|
||||
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
|
||||
.await
|
||||
.context("Failed to set up zone A sequencer")?;
|
||||
let (_seq_b, _seq_b_home) =
|
||||
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone.clone()))
|
||||
.await
|
||||
.context("Failed to set up zone B sequencer")?;
|
||||
let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone))
|
||||
.await
|
||||
.context("Failed to set up zone B indexer")?;
|
||||
|
||||
// Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B.
|
||||
let lock = build_lock_tx(&holder_key, holder_id, zone_b);
|
||||
sequencer_client(seq_a.addr())?
|
||||
.send_transaction(lock)
|
||||
.await
|
||||
.context("Failed to submit lock on zone A")?;
|
||||
|
||||
// Wait until zone B's indexer reflects the verified mint.
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
|
||||
.context("Failed to build indexer URL")?;
|
||||
let indexer = IndexerClient::new(&indexer_url)
|
||||
.await
|
||||
.context("Failed to build indexer client")?;
|
||||
|
||||
let minted = wait_for_mint(&indexer, holding_id).await?;
|
||||
assert_eq!(
|
||||
minted, LOCK_AMOUNT,
|
||||
"zone B must mint exactly the locked amount"
|
||||
);
|
||||
|
||||
// Conservation: the mint on B must be backed by an equal lock on A. The lock
|
||||
// has already landed (it preceded delivery), so zone A reflects the debit and
|
||||
// escrow now.
|
||||
let seq_a_client = sequencer_client(seq_a.addr())?;
|
||||
let escrow_id = bridge_lock_core::escrow_account_id(Program::bridge_lock().id());
|
||||
let escrowed =
|
||||
bridge_lock_core::read_balance(&seq_a_client.get_account(escrow_id).await?.data.into_inner());
|
||||
assert_eq!(escrowed, LOCK_AMOUNT, "zone A escrow must hold the locked amount");
|
||||
let remaining =
|
||||
bridge_lock_core::read_balance(&seq_a_client.get_account(holder_id).await?.data.into_inner());
|
||||
assert_eq!(
|
||||
remaining,
|
||||
INITIAL_BALANCE - LOCK_AMOUNT,
|
||||
"zone A holder must be debited by the locked amount"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds a signed bridge_lock Lock that forwards a wrapped-token Mint of the
|
||||
/// locked amount to the recipient on the target zone.
|
||||
fn build_lock_tx(holder_key: &PrivateKey, holder_id: AccountId, target_zone: [u8; 32]) -> LeeTransaction {
|
||||
let bridge_lock_id = Program::bridge_lock().id();
|
||||
let wrapped_token_id = Program::wrapped_token().id();
|
||||
let outbox_id = Program::cross_zone_outbox().id();
|
||||
let ordinal = 0;
|
||||
|
||||
let mint = wrapped_token_core::Instruction::Mint {
|
||||
recipient: RECIPIENT,
|
||||
amount: LOCK_AMOUNT,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
|
||||
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
|
||||
|
||||
let target_accounts = vec![
|
||||
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
|
||||
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
|
||||
];
|
||||
let lock = bridge_lock_core::Instruction::Lock {
|
||||
amount: LOCK_AMOUNT,
|
||||
target_zone,
|
||||
target_program_id: wrapped_token_id,
|
||||
target_accounts,
|
||||
payload,
|
||||
outbox_program_id: outbox_id,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let accounts = vec![
|
||||
holder_id,
|
||||
bridge_lock_core::escrow_account_id(bridge_lock_id),
|
||||
outbox_pda(outbox_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)
|
||||
.expect("build lock message");
|
||||
let witness = WitnessSet::for_message(&message, &[holder_key]);
|
||||
LeeTransaction::Public(PublicTransaction::new(message, witness))
|
||||
}
|
||||
|
||||
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
|
||||
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
|
||||
.context("Failed to build sequencer URL")?;
|
||||
SequencerClientBuilder::default()
|
||||
.build(url)
|
||||
.context("Failed to build sequencer client")
|
||||
}
|
||||
|
||||
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
|
||||
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
|
||||
let account_id = indexer_service_protocol::AccountId {
|
||||
value: holding_id.into_value(),
|
||||
};
|
||||
let wait = async {
|
||||
loop {
|
||||
let account =
|
||||
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
|
||||
let balance = wrapped_token_core::read_balance(&account.data.0);
|
||||
if balance != 0 {
|
||||
return Ok::<u128, anyhow::Error>(balance);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
|
||||
.await
|
||||
.context("Zone B's indexer did not mint the wrapped token in time")?
|
||||
}
|
||||
@ -36,6 +36,8 @@ lee_core = { workspace = true, features = ["test_utils"] }
|
||||
token_core.workspace = true
|
||||
authenticated_transfer_core.workspace = true
|
||||
cross_zone_inbox_core.workspace = true
|
||||
cross_zone_outbox_core.workspace = true
|
||||
bridge_lock_core.workspace = true
|
||||
ping_core.workspace = true
|
||||
test_program_methods.workspace = true
|
||||
|
||||
|
||||
275
lee/state_machine/src/cross_zone_bridge_tests.rs
Normal file
275
lee/state_machine/src/cross_zone_bridge_tests.rs
Normal file
@ -0,0 +1,275 @@
|
||||
#![expect(
|
||||
clippy::tests_outside_test_module,
|
||||
clippy::arithmetic_side_effects,
|
||||
reason = "We don't care about these in tests"
|
||||
)]
|
||||
|
||||
//! Single-zone state-machine tests for the wrapped-token bridge (Demo 2). They
|
||||
//! drive the two guest hops in isolation, no watcher or Bedrock: the source
|
||||
//! `bridge_lock::Lock` (which escrows and chains `outbox::Emit`), then a
|
||||
//! hand-built `cross_zone_inbox::Dispatch` carrying the wrapped-token mint to
|
||||
//! `wrapped_token::Mint`. Fast, so they pin guest logic before the e2e exercises
|
||||
//! the plumbing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cross_zone_inbox_core::{
|
||||
CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard,
|
||||
inbox_config_account_id, inbox_seen_shard_account_id, message_key,
|
||||
};
|
||||
use cross_zone_outbox_core::{OutboxRecord, outbox_pda};
|
||||
use lee_core::account::Account;
|
||||
|
||||
use crate::{
|
||||
AccountId, PrivateKey, PublicKey, PublicTransaction, V03State,
|
||||
program::Program,
|
||||
public_transaction::{Message, WitnessSet},
|
||||
validated_state_diff::ValidatedStateDiff,
|
||||
};
|
||||
|
||||
const INITIAL_BALANCE: u128 = 100;
|
||||
const LOCK_AMOUNT: u128 = 30;
|
||||
const RECIPIENT: [u8; 32] = [9; 32];
|
||||
|
||||
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
|
||||
/// payload (risc0 words, little-endian bytes).
|
||||
fn mint_payload() -> Vec<u8> {
|
||||
let mint = wrapped_token_core::Instruction::Mint {
|
||||
recipient: RECIPIENT,
|
||||
amount: LOCK_AMOUNT,
|
||||
};
|
||||
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
|
||||
words.iter().flat_map(|word| word.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// Drives `bridge_lock::Lock` and asserts it debits the holder, credits the
|
||||
/// escrow, and records the forwarded mint in the outbox PDA.
|
||||
#[test]
|
||||
fn lock_escrows_balance_and_emits_to_outbox() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
|
||||
let bridge_lock_id = Program::bridge_lock().id();
|
||||
let wrapped_token_id = Program::wrapped_token().id();
|
||||
let outbox_id = Program::cross_zone_outbox().id();
|
||||
let zone_b = [2_u8; 32];
|
||||
let ordinal = 0;
|
||||
|
||||
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
|
||||
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
|
||||
state.force_insert_account(
|
||||
holder_id,
|
||||
Account {
|
||||
program_owner: bridge_lock_id,
|
||||
balance: 0,
|
||||
data: bridge_lock_core::balance_bytes(INITIAL_BALANCE)
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.expect("balance fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
|
||||
let payload = mint_payload();
|
||||
let target_accounts = vec![
|
||||
wrapped_token_core::config_account_id(wrapped_token_id).into_value(),
|
||||
wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(),
|
||||
];
|
||||
let lock = bridge_lock_core::Instruction::Lock {
|
||||
amount: LOCK_AMOUNT,
|
||||
target_zone: zone_b,
|
||||
target_program_id: wrapped_token_id,
|
||||
target_accounts,
|
||||
payload: payload.clone(),
|
||||
outbox_program_id: outbox_id,
|
||||
ordinal,
|
||||
};
|
||||
|
||||
let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id);
|
||||
let outbox_record_id = outbox_pda(outbox_id, &zone_b, ordinal);
|
||||
let message = Message::try_new(
|
||||
bridge_lock_id,
|
||||
vec![holder_id, escrow_id, outbox_record_id],
|
||||
vec![0_u128.into()],
|
||||
lock,
|
||||
)
|
||||
.expect("build lock message");
|
||||
let witness = WitnessSet::for_message(&message, &[&holder_key]);
|
||||
let tx = PublicTransaction::new(message, witness);
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("lock must validate and execute");
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
let holder_after =
|
||||
bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner());
|
||||
assert_eq!(holder_after, INITIAL_BALANCE - LOCK_AMOUNT, "holder debited");
|
||||
|
||||
let escrow_after =
|
||||
bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner());
|
||||
assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited");
|
||||
|
||||
let record = OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner())
|
||||
.expect("outbox PDA holds an OutboxRecord");
|
||||
assert_eq!(record.target_zone, zone_b);
|
||||
assert_eq!(record.target_program_id, wrapped_token_id);
|
||||
assert_eq!(record.payload, payload, "emitted payload is the wrapped mint");
|
||||
}
|
||||
|
||||
/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject)
|
||||
/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient.
|
||||
#[test]
|
||||
fn inbox_dispatch_mints_wrapped_token() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
|
||||
let inbox_id = Program::cross_zone_inbox().id();
|
||||
let wrapped_token_id = Program::wrapped_token().id();
|
||||
|
||||
let self_zone = [1_u8; 32];
|
||||
let src_zone = [2_u8; 32];
|
||||
let src_block_id = 5;
|
||||
|
||||
// Seed the inbox config allowing src_zone -> wrapped_token.
|
||||
let mut allowed_targets = BTreeMap::new();
|
||||
allowed_targets.insert(src_zone, vec![wrapped_token_id]);
|
||||
let config = InboxConfig {
|
||||
self_zone,
|
||||
allowed_peers: BTreeMap::new(),
|
||||
allowed_targets,
|
||||
};
|
||||
let config_id = inbox_config_account_id(inbox_id);
|
||||
state.force_insert_account(
|
||||
config_id,
|
||||
Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("config fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index: 0,
|
||||
src_program_id: [9_u32; 8],
|
||||
target_program_id: wrapped_token_id,
|
||||
payload: mint_payload(),
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
|
||||
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
|
||||
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
|
||||
let message = Message::try_new(
|
||||
inbox_id,
|
||||
vec![config_id, seen_id, wrapped_config_id, holding_id],
|
||||
vec![],
|
||||
InboxInstruction::Dispatch(msg),
|
||||
)
|
||||
.expect("build dispatch message");
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("dispatch must validate and execute");
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
let minted =
|
||||
wrapped_token_core::read_balance(&public_diff[&holding_id].data.clone().into_inner());
|
||||
assert_eq!(minted, LOCK_AMOUNT, "recipient holding minted the locked amount");
|
||||
}
|
||||
|
||||
/// A dispatch whose message key is already in the seen-shard is an idempotent
|
||||
/// no-op: the inbox makes no chained call, so the wrapped token is not minted a
|
||||
/// second time. This is the bridge's replay defense.
|
||||
#[test]
|
||||
fn mint_replay_rejected() {
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
|
||||
let inbox_id = Program::cross_zone_inbox().id();
|
||||
let wrapped_token_id = Program::wrapped_token().id();
|
||||
|
||||
let self_zone = [1_u8; 32];
|
||||
let src_zone = [2_u8; 32];
|
||||
let src_block_id = 5;
|
||||
let src_tx_index = 0;
|
||||
|
||||
let mut allowed_targets = BTreeMap::new();
|
||||
allowed_targets.insert(src_zone, vec![wrapped_token_id]);
|
||||
let config = InboxConfig {
|
||||
self_zone,
|
||||
allowed_peers: BTreeMap::new(),
|
||||
allowed_targets,
|
||||
};
|
||||
let config_id = inbox_config_account_id(inbox_id);
|
||||
state.force_insert_account(
|
||||
config_id,
|
||||
Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: config
|
||||
.to_bytes()
|
||||
.try_into()
|
||||
.expect("config fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
|
||||
// Seed the seen-shard as already containing this message's key, so the inbox
|
||||
// takes the replay no-op branch. The shard is inbox-owned (claimed on a prior
|
||||
// delivery), so the guest leaves it untouched.
|
||||
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
|
||||
let mut shard = SeenShard::default();
|
||||
shard.insert(message_key(&src_zone, src_block_id, src_tx_index));
|
||||
state.force_insert_account(
|
||||
seen_id,
|
||||
Account {
|
||||
program_owner: inbox_id,
|
||||
balance: 0,
|
||||
data: shard.to_bytes().try_into().expect("shard fits in account data"),
|
||||
nonce: 0_u128.into(),
|
||||
},
|
||||
);
|
||||
|
||||
let msg = CrossZoneMessage {
|
||||
src_zone,
|
||||
src_block_id,
|
||||
src_tx_index,
|
||||
src_program_id: [9_u32; 8],
|
||||
target_program_id: wrapped_token_id,
|
||||
payload: mint_payload(),
|
||||
l1_inclusion_witness: None,
|
||||
};
|
||||
|
||||
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
|
||||
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
|
||||
|
||||
let message = Message::try_new(
|
||||
inbox_id,
|
||||
vec![config_id, seen_id, wrapped_config_id, holding_id],
|
||||
vec![],
|
||||
InboxInstruction::Dispatch(msg),
|
||||
)
|
||||
.expect("build dispatch message");
|
||||
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
|
||||
|
||||
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
|
||||
.expect("a replayed dispatch is a valid no-op, not an error");
|
||||
let public_diff = diff.public_diff();
|
||||
|
||||
// No mint: the holding is never credited on replay.
|
||||
let minted = public_diff
|
||||
.get(&holding_id)
|
||||
.map_or(0, |account| wrapped_token_core::read_balance(&account.data.clone().into_inner()));
|
||||
assert_eq!(minted, 0, "a replayed message must not mint again");
|
||||
|
||||
// The seen-shard is untouched by the no-op.
|
||||
if let Some(seen) = public_diff.get(&seen_id) {
|
||||
let shard_after = SeenShard::from_bytes(&seen.data.clone().into_inner())
|
||||
.expect("seen shard decodes");
|
||||
assert_eq!(shard_after, shard, "replay must not modify the seen-shard");
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,9 @@ mod validated_state_diff;
|
||||
#[cfg(test)]
|
||||
mod cross_zone_dispatch_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cross_zone_bridge_tests;
|
||||
|
||||
pub mod program_methods {
|
||||
include!(concat!(env!("OUT_DIR"), "/program_methods/mod.rs"));
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user