use std::borrow::Cow;
use std::sync::Arc;
use testcontainers::{
ContainerAsync, CopyToContainer, Image,
core::{ContainerPort, WaitFor},
runners::AsyncRunner,
};
use tokio::sync::OnceCell;
use tracing::info;
use crate::client::Near;
const DEFAULT_IMAGE: &str = "nearprotocol/sandbox";
const DEFAULT_VERSION: &str = "2.11.0";
const RPC_PORT: ContainerPort = ContainerPort::Tcp(3030);
#[derive(Debug, Clone)]
struct NearSandbox {
image_name: String,
tag: String,
env_vars: Vec<(String, String)>,
copy_to_sources: Vec<CopyToContainer>,
}
impl NearSandbox {
fn new(version: &str) -> Self {
let (image_name, tag) = match std::env::var("NEAR_SANDBOX_IMAGE") {
Ok(raw) => {
let val = raw.trim();
if val.is_empty() {
(DEFAULT_IMAGE.to_owned(), version.to_owned())
} else {
match val.rsplit_once(':') {
Some((name, tag)) if !name.is_empty() && !tag.is_empty() => {
(name.to_owned(), tag.to_owned())
}
_ => (val.to_owned(), version.to_owned()),
}
}
}
Err(_) => (DEFAULT_IMAGE.to_owned(), version.to_owned()),
};
Self {
image_name,
tag,
env_vars: vec![
("NEAR_ENABLE_SANDBOX_LOG".to_owned(), "1".to_owned()),
],
copy_to_sources: Vec::new(),
}
}
fn with_account_id(mut self, account_id: impl Into<String>) -> Self {
self.env_vars
.push(("NEAR_ROOT_ACCOUNT".to_owned(), account_id.into()));
self
}
fn with_chain_id(mut self, chain_id: impl Into<String>) -> Self {
self.env_vars
.push(("NEAR_CHAIN_ID".to_owned(), chain_id.into()));
self
}
}
impl Default for NearSandbox {
fn default() -> Self {
Self::new(DEFAULT_VERSION)
}
}
impl Image for NearSandbox {
fn name(&self) -> &str {
&self.image_name
}
fn tag(&self) -> &str {
&self.tag
}
fn ready_conditions(&self) -> Vec<WaitFor> {
vec![WaitFor::healthcheck()]
}
fn env_vars(
&self,
) -> impl IntoIterator<Item = (impl Into<Cow<'_, str>>, impl Into<Cow<'_, str>>)> {
self.env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
fn copy_to_sources(&self) -> impl IntoIterator<Item = &CopyToContainer> {
&self.copy_to_sources
}
fn expose_ports(&self) -> &[ContainerPort] {
&[RPC_PORT]
}
}
pub use crate::client::{SANDBOX_ROOT_ACCOUNT, SANDBOX_ROOT_SECRET_KEY, SandboxNetwork};
static SHARED_SANDBOX: OnceCell<Sandbox> = OnceCell::const_new();
extern "C" fn cleanup_shared_sandbox() {
let handle = std::thread::spawn(|| {
if let Some(sandbox) = SHARED_SANDBOX.get() {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
let _ = rt.block_on(sandbox.container.stop_with_timeout(Some(0)));
}
}
});
let _ = handle.join();
}
fn register_shared_cleanup() {
unsafe {
libc::atexit(cleanup_shared_sandbox);
}
}
#[derive(Clone)]
pub struct Sandbox {
#[allow(dead_code)]
container: Arc<ContainerAsync<NearSandbox>>,
rpc_url: String,
root_account: String,
chain_id: Option<String>,
}
impl Sandbox {
async fn start(version: &str, root_account: String, chain_id: Option<String>) -> Self {
info!(
version = version,
root_account = %root_account,
chain_id = chain_id.as_deref().unwrap_or("(default)"),
"Starting sandbox container"
);
let mut image = NearSandbox::new(version).with_account_id(&root_account);
if let Some(ref id) = chain_id {
image = image.with_chain_id(id);
}
let container = image
.start()
.await
.expect("Failed to start sandbox container");
let host = container
.get_host()
.await
.expect("Failed to get sandbox host");
let host_port = container
.get_host_port_ipv4(RPC_PORT)
.await
.expect("Failed to get mapped port for sandbox RPC");
let rpc_url = format!("http://{}:{}", host, host_port);
info!(rpc_url = %rpc_url, "Sandbox container ready");
Self {
container: Arc::new(container),
rpc_url,
root_account,
chain_id,
}
}
pub fn client(&self) -> Near {
Near::sandbox(self)
}
pub async fn set_balance(
&self,
account_id: impl Into<crate::AccountId>,
balance: crate::NearToken,
) -> Result<(), crate::Error> {
let near = self.client();
let account_id: crate::AccountId = account_id.into();
let mut account_response: serde_json::Value = near
.rpc()
.call(
"query",
serde_json::json!({
"finality": "optimistic",
"request_type": "view_account",
"account_id": account_id.to_string()
}),
)
.await
.map_err(|e| crate::Error::Rpc(Box::new(e)))?;
if let Some(obj) = account_response.as_object_mut() {
obj.insert(
"amount".to_string(),
serde_json::Value::String(balance.as_yoctonear().to_string()),
);
}
let records = serde_json::json!([
{
"Account": {
"account_id": account_id.to_string(),
"account": account_response
}
}
]);
near.rpc()
.sandbox_patch_state(records)
.await
.map_err(|e| crate::Error::Rpc(Box::new(e)))
}
pub async fn fast_forward(&self, delta_height: u64) -> Result<(), crate::Error> {
self.client()
.rpc()
.sandbox_fast_forward(delta_height)
.await?;
Ok(())
}
}
impl SandboxNetwork for Sandbox {
fn rpc_url(&self) -> &str {
&self.rpc_url
}
fn root_account_id(&self) -> &str {
&self.root_account
}
fn root_secret_key(&self) -> &str {
SANDBOX_ROOT_SECRET_KEY
}
fn chain_id(&self) -> Option<&str> {
self.chain_id.as_deref()
}
}
pub struct SandboxConfig;
impl SandboxConfig {
pub async fn shared() -> &'static Sandbox {
SHARED_SANDBOX
.get_or_init(|| async {
let sandbox =
Sandbox::start(DEFAULT_VERSION, SANDBOX_ROOT_ACCOUNT.to_string(), None).await;
register_shared_cleanup();
sandbox
})
.await
}
pub async fn fresh() -> Sandbox {
Sandbox::start(DEFAULT_VERSION, SANDBOX_ROOT_ACCOUNT.to_string(), None).await
}
pub fn builder() -> SandboxBuilder {
SandboxBuilder::new()
}
}
pub struct SandboxBuilder {
version: Option<String>,
root_account: Option<String>,
chain_id: Option<String>,
}
impl SandboxBuilder {
fn new() -> Self {
Self {
version: None,
root_account: None,
chain_id: None,
}
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn root_account(mut self, name: impl crate::TryIntoAccountId) -> Self {
let account_id = name
.try_into_account_id()
.expect("invalid sandbox root account name");
self.root_account = Some(account_id.to_string());
self
}
pub fn chain_id(mut self, chain_id: impl Into<String>) -> Self {
self.chain_id = Some(chain_id.into());
self
}
pub async fn fresh(self) -> Sandbox {
let version = self.version.as_deref().unwrap_or(DEFAULT_VERSION);
let root_account = self
.root_account
.unwrap_or_else(|| SANDBOX_ROOT_ACCOUNT.to_string());
Sandbox::start(version, root_account, self.chain_id).await
}
pub async fn shared(self) -> &'static Sandbox {
let version = self.version;
let root_account = self.root_account;
let chain_id = self.chain_id;
SHARED_SANDBOX
.get_or_init(|| async {
let v = version.as_deref().unwrap_or(DEFAULT_VERSION);
let r = root_account.unwrap_or_else(|| SANDBOX_ROOT_ACCOUNT.to_string());
let sandbox = Sandbox::start(v, r, chain_id).await;
register_shared_cleanup();
sandbox
})
.await
}
}