docs(contract): expand contract docs
This commit is contained in:
parent
1adbca67b0
commit
030fc671fe
|
@ -299,6 +299,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"rustc-hex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
@ -1142,9 +1143,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.53"
|
||||
version = "1.0.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993948e75b189211a9b31a7528f950c6adc21f9720b6438ff80a7fa2f864cea2"
|
||||
checksum = "ec2c5d7e739bc07a3e73381a39d61fdb5f671c60c1df26a130690665803d8226"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
|
|
|
@ -20,6 +20,7 @@ tokio = { version = "0.2.21", default-features = false }
|
|||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "0.2.21", default-features = false, features = ["macros"] }
|
||||
serde_json = "1.0.55"
|
||||
|
||||
[features]
|
||||
default = ["abigen"]
|
||||
|
|
|
@ -10,32 +10,45 @@ use std::{fmt::Debug, marker::PhantomData};
|
|||
use thiserror::Error as ThisError;
|
||||
|
||||
#[derive(ThisError, Debug)]
|
||||
/// An Error which is thrown when interacting with a smart contract
|
||||
pub enum ContractError {
|
||||
/// Thrown when the ABI decoding fails
|
||||
#[error(transparent)]
|
||||
DecodingError(#[from] AbiError),
|
||||
|
||||
/// Thrown when detokenizing an argument
|
||||
#[error(transparent)]
|
||||
DetokenizationError(#[from] InvalidOutputType),
|
||||
|
||||
/// Thrown when a client call fails
|
||||
#[error(transparent)]
|
||||
ClientError(#[from] ClientError),
|
||||
|
||||
/// Thrown when a provider call fails
|
||||
#[error(transparent)]
|
||||
ProviderError(#[from] ProviderError),
|
||||
|
||||
/// Thrown during deployment if a constructor argument was passed in the `deploy`
|
||||
/// call but a constructor was not present in the ABI
|
||||
#[error("constructor is not defined in the ABI")]
|
||||
ConstructorError,
|
||||
|
||||
/// Thrown if a contract address is not found in the deployment transaction's
|
||||
/// receipt
|
||||
#[error("Contract was not deployed")]
|
||||
ContractNotDeployed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Helper for managing a transaction before submitting it to a node
|
||||
pub struct ContractCall<'a, P, S, D> {
|
||||
pub(crate) tx: TransactionRequest,
|
||||
pub(crate) function: Function,
|
||||
/// The raw transaction object
|
||||
pub tx: TransactionRequest,
|
||||
/// The ABI of the function being called
|
||||
pub function: Function,
|
||||
/// Optional block number to be used when calculating the transaction's gas and nonce
|
||||
pub block: Option<BlockNumber>,
|
||||
pub(crate) client: &'a Client<P, S>,
|
||||
pub(crate) block: Option<BlockNumber>,
|
||||
pub(crate) datatype: PhantomData<D>,
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{ContractCall, Event};
|
||||
use super::{call::ContractCall, event::Event};
|
||||
|
||||
use ethers_core::{
|
||||
abi::{Abi, Detokenize, Error, EventExt, Function, FunctionExt, Tokenize},
|
||||
|
@ -10,10 +10,153 @@ use ethers_signers::{Client, Signer};
|
|||
use rustc_hex::ToHex;
|
||||
use std::{collections::HashMap, fmt::Debug, hash::Hash, marker::PhantomData};
|
||||
|
||||
/// Represents a contract instance at an address. Provides methods for
|
||||
/// contract interaction.
|
||||
// TODO: Should we separate the lifetimes for the two references?
|
||||
// https://stackoverflow.com/a/29862184
|
||||
/// A Contract is an abstraction of an executable program on the Ethereum Blockchain.
|
||||
/// It has code (called byte code) as well as allocated long-term memory
|
||||
/// (called storage). Every deployed Contract has an address, which is used to connect
|
||||
/// to it so that it may be sent messages to call its methods.
|
||||
///
|
||||
/// A Contract can emit Events, which can be efficiently observed by applications
|
||||
/// to be notified when a contract has performed specific operation.
|
||||
///
|
||||
/// There are two types of methods that can be called on a Contract:
|
||||
///
|
||||
/// 1. A Constant method may not add, remove or change any data in the storage,
|
||||
/// nor log any events, and may only call Constant methods on other contracts.
|
||||
/// These methods are free (no Ether is required) to call. The result from them
|
||||
/// may also be returned to the caller. Constant methods are marked as `pure` and
|
||||
/// `view` in Solidity.
|
||||
///
|
||||
/// 2. A Non-Constant method requires a fee (in Ether) to be paid, but may perform
|
||||
/// any state-changing operation desired, log events, send ether and call Non-Constant
|
||||
/// methods on other Contracts. These methods cannot return their result to the caller.
|
||||
/// These methods must be triggered by a transaction, sent by an Externally Owned Account
|
||||
/// (EOA) either directly or indirectly (i.e. called from another contract), and are
|
||||
/// required to be mined before the effects are present. Therefore, the duration
|
||||
/// required for these operations can vary widely, and depend on the transaction
|
||||
/// gas price, network congestion and miner priority heuristics.
|
||||
///
|
||||
/// The Contract API provides simple way to connect to a Contract and call its methods,
|
||||
/// as functions on a Rust struct, handling all the binary protocol conversion,
|
||||
/// internal name mangling and topic construction. This allows a Contract object
|
||||
/// to be used like any standard Rust struct, without having to worry about the
|
||||
/// low-level details of the Ethereum Virtual Machine or Blockchain.
|
||||
///
|
||||
/// The Contract definition (called an Application Binary Interface, or ABI) must
|
||||
/// be provided to instantiate a contract and the available methods and events will
|
||||
/// be made available to call by providing their name as a `str` via the [`method`]
|
||||
/// and [`event`] methods. If non-existing names are given, the function/event call
|
||||
/// will fail.
|
||||
///
|
||||
/// Alternatively, you can _and should_ use the [`abigen`] macro, or the [`Abigen` builder]
|
||||
/// to generate type-safe bindings to your contracts.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Assuming we already have our contract deployed at `address`, we'll proceed to
|
||||
/// interact with its methods and retrieve raw logs it has emitted.
|
||||
///
|
||||
/// ```no_run
|
||||
/// use ethers_core::{abi::Abi, utils::Solc, types::{Address, H256}};
|
||||
/// use ethers_contract::Contract;
|
||||
/// use ethers_providers::{Provider, Http};
|
||||
/// use ethers_signers::Wallet;
|
||||
/// use std::convert::TryFrom;
|
||||
///
|
||||
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// // this is a fake address used just for this example
|
||||
/// let address = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".parse::<Address>()?;
|
||||
///
|
||||
/// // (ugly way to write the ABI inline, you can otherwise read it from a file)
|
||||
/// let abi: Abi = serde_json::from_str(r#"[{"inputs":[{"internalType":"string","name":"value","type":"string"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"author","type":"address"},{"indexed":true,"internalType":"address","name":"oldAuthor","type":"address"},{"indexed":false,"internalType":"string","name":"oldValue","type":"string"},{"indexed":false,"internalType":"string","name":"newValue","type":"string"}],"name":"ValueChanged","type":"event"},{"inputs":[],"name":"getValue","outputs":[{"internalType":"string","name":"","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"lastSender","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"value","type":"string"}],"name":"setValue","outputs":[],"stateMutability":"nonpayable","type":"function"}]"#)?;
|
||||
///
|
||||
/// // connect to the network
|
||||
/// let provider = Provider::<Http>::try_from("http://localhost:8545").unwrap();
|
||||
/// let client = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc"
|
||||
/// .parse::<Wallet>()?.connect(provider);
|
||||
///
|
||||
/// // create the contract object at the address
|
||||
/// let contract = Contract::new(address, &abi, &client);
|
||||
///
|
||||
/// // Calling constant methods is done by calling `call()` on the method builder.
|
||||
/// // (if the function takes no arguments, then you must use `()` as the argument)
|
||||
/// let init_value: String = contract
|
||||
/// .method::<_, String>("getValue", ())?
|
||||
/// .call()
|
||||
/// .await?;
|
||||
///
|
||||
/// // Non-constant methods are executed via the `send()` call on the method builder.
|
||||
/// let tx_hash = contract
|
||||
/// .method::<_, H256>("setValue", "hi".to_owned())?
|
||||
/// .send()
|
||||
/// .await?;
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # Event Logging
|
||||
/// Querying structured logs requires you to have defined a struct with the expected
|
||||
/// datatypes and to have implemented `Detokenize` for it. This boilerplate code
|
||||
/// is generated for you via the [`abigen`] and [`Abigen` builder] utilities.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// use ethers_core::{abi::Abi, types::Address};
|
||||
/// use ethers_contract::Contract;
|
||||
/// use ethers_providers::{Provider, Http};
|
||||
/// use ethers_signers::Wallet;
|
||||
/// use std::convert::TryFrom;
|
||||
/// use ethers_core::abi::{Detokenize, Token, InvalidOutputType};
|
||||
/// # // this is a fake address used just for this example
|
||||
/// # let address = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".parse::<Address>()?;
|
||||
/// # let abi: Abi = serde_json::from_str(r#"[]"#)?;
|
||||
/// # let provider = Provider::<Http>::try_from("http://localhost:8545").unwrap();
|
||||
/// # let client = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc".parse::<Wallet>()?.connect(provider);
|
||||
/// # let contract = Contract::new(address, &abi, &client);
|
||||
///
|
||||
/// #[derive(Clone, Debug)]
|
||||
/// struct ValueChanged {
|
||||
/// old_author: Address,
|
||||
/// new_author: Address,
|
||||
/// old_value: String,
|
||||
/// new_value: String,
|
||||
/// }
|
||||
///
|
||||
/// impl Detokenize for ValueChanged {
|
||||
/// fn from_tokens(tokens: Vec<Token>) -> Result<ValueChanged, InvalidOutputType> {
|
||||
/// let old_author: Address = tokens[1].clone().to_address().unwrap();
|
||||
/// let new_author: Address = tokens[1].clone().to_address().unwrap();
|
||||
/// let old_value = tokens[2].clone().to_string().unwrap();
|
||||
/// let new_value = tokens[3].clone().to_string().unwrap();
|
||||
///
|
||||
/// Ok(Self {
|
||||
/// old_author,
|
||||
/// new_author,
|
||||
/// old_value,
|
||||
/// new_value,
|
||||
/// })
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
///
|
||||
/// let logs: Vec<ValueChanged> = contract
|
||||
/// .event("ValueChanged")?
|
||||
/// .from_block(0u64)
|
||||
/// .query()
|
||||
/// .await?;
|
||||
///
|
||||
/// println!("{:?}", logs);
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
///
|
||||
/// ```
|
||||
///
|
||||
/// _Disclaimer: these above docs have been adapted from the corresponding [ethers.js page](https://docs.ethers.io/ethers.js/html/api-contract.html)_
|
||||
///
|
||||
/// [`abigen`]: macro.abigen.html
|
||||
/// [`Abigen` builder]: struct.Abigen.html
|
||||
/// [`event`]: struct.Contract.html#method.event
|
||||
/// [`method`]: struct.Contract.html#method.method
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Contract<'a, P, S> {
|
||||
client: &'a Client<P, S>,
|
||||
|
|
|
@ -9,42 +9,51 @@ use ethers_core::{
|
|||
|
||||
use std::{collections::HashMap, marker::PhantomData};
|
||||
|
||||
/// Helper for managing the event filter before querying or streaming its logs
|
||||
pub struct Event<'a: 'b, 'b, P, D> {
|
||||
/// The event filter's state
|
||||
pub filter: Filter,
|
||||
/// The ABI of the event which is being filtered
|
||||
pub event: &'b AbiEvent,
|
||||
pub(crate) provider: &'a Provider<P>,
|
||||
pub(crate) event: &'b AbiEvent,
|
||||
pub(crate) datatype: PhantomData<D>,
|
||||
}
|
||||
|
||||
// TODO: Improve these functions
|
||||
impl<P, D: Detokenize> Event<'_, '_, P, D> {
|
||||
/// Sets the filter's `from` block
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn from_block<T: Into<BlockNumber>>(mut self, block: T) -> Self {
|
||||
self.filter.from_block = Some(block.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the filter's `to` block
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub fn to_block<T: Into<BlockNumber>>(mut self, block: T) -> Self {
|
||||
self.filter.to_block = Some(block.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the filter's 0th topic (typically the event name for non-anonymous events)
|
||||
pub fn topic0<T: Into<ValueOrArray<H256>>>(mut self, topic: T) -> Self {
|
||||
self.filter.topics[0] = Some(topic.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the filter's 1st topic
|
||||
pub fn topic1<T: Into<ValueOrArray<H256>>>(mut self, topic: T) -> Self {
|
||||
self.filter.topics[1] = Some(topic.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the filter's 2nd topic
|
||||
pub fn topic2<T: Into<ValueOrArray<H256>>>(mut self, topic: T) -> Self {
|
||||
self.filter.topics[2] = Some(topic.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the filter's 3rd topic
|
||||
pub fn topic3<T: Into<ValueOrArray<H256>>>(mut self, topic: T) -> Self {
|
||||
self.filter.topics[3] = Some(topic.into());
|
||||
self
|
||||
|
|
|
@ -11,13 +11,14 @@ use std::time::Duration;
|
|||
use tokio::time;
|
||||
|
||||
/// Poll for tx confirmation once every 7 seconds.
|
||||
/// TODO: Can this be improved by replacing polling with an "on new block" subscription?
|
||||
// TODO: Can this be improved by replacing polling with an "on new block" subscription?
|
||||
const POLL_INTERVAL: u64 = 7000;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Helper which manages the deployment transaction of a smart contract
|
||||
pub struct Deployer<'a, P, S> {
|
||||
abi: &'a Abi,
|
||||
client: &'a Client<P, S>,
|
||||
pub abi: &'a Abi,
|
||||
tx: TransactionRequest,
|
||||
confs: usize,
|
||||
poll_interval: Duration,
|
||||
|
@ -28,16 +29,22 @@ where
|
|||
S: Signer,
|
||||
P: JsonRpcClient,
|
||||
{
|
||||
/// Sets the poll frequency for checking the number of confirmations for
|
||||
/// the contract deployment transaction
|
||||
pub fn poll_interval<T: Into<Duration>>(mut self, interval: T) -> Self {
|
||||
self.poll_interval = interval.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the number of confirmations to wait for the contract deployment transaction
|
||||
pub fn confirmations<T: Into<usize>>(mut self, confirmations: T) -> Self {
|
||||
self.confs = confirmations.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Broadcasts the contract deployment transaction and after waiting for it to
|
||||
/// be sufficiently confirmed (default: 1), it returns a [`Contract`](./struct.Contract.html)
|
||||
/// struct at the deployed contract's address.
|
||||
pub async fn send(self) -> Result<Contract<'a, P, S>, ContractError> {
|
||||
let tx_hash = self.client.send_transaction(self.tx, None).await?;
|
||||
|
||||
|
@ -58,16 +65,60 @@ where
|
|||
Ok(contract)
|
||||
}
|
||||
|
||||
/// Returns a reference to the deployer's ABI
|
||||
pub fn abi(&self) -> &Abi {
|
||||
&self.abi
|
||||
}
|
||||
|
||||
/// Returns a reference to the deployer's client
|
||||
pub fn client(&self) -> &Client<P, S> {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// To deploy a contract to the Ethereum network, a `ContractFactory` can be
|
||||
/// created which manages the Contract bytecode and Application Binary Interface
|
||||
/// (ABI), usually generated from the Solidity compiler.
|
||||
///
|
||||
/// Once the factory's deployment transaction is mined with sufficient confirmations,
|
||||
/// the [`Contract`](./struct.Contract.html) object is returned.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use ethers_core::utils::Solc;
|
||||
/// use ethers_contract::ContractFactory;
|
||||
/// use ethers_providers::{Provider, Http};
|
||||
/// use ethers_signers::Wallet;
|
||||
/// use std::convert::TryFrom;
|
||||
///
|
||||
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// // first we'll compile the contract (you can alternatively compile it yourself
|
||||
/// // and pass the ABI/Bytecode
|
||||
/// let compiled = Solc::new("./tests/contract.sol").build().unwrap();
|
||||
/// let contract = compiled
|
||||
/// .get("SimpleStorage")
|
||||
/// .expect("could not find contract");
|
||||
///
|
||||
/// // connect to the network
|
||||
/// let provider = Provider::<Http>::try_from("http://localhost:8545").unwrap();
|
||||
/// let client = "380eb0f3d505f087e438eca80bc4df9a7faa24f868e69fc0440261a0fc0567dc"
|
||||
/// .parse::<Wallet>()?.connect(provider);
|
||||
///
|
||||
/// // create a factory which will be used to deploy instances of the contract
|
||||
/// let factory = ContractFactory::new(&contract.abi, &contract.bytecode, &client);
|
||||
///
|
||||
/// // The deployer created by the `deploy` call exposes a builder which gets consumed
|
||||
/// // by the async `send` call
|
||||
/// let contract = factory
|
||||
/// .deploy("initial value".to_string())?
|
||||
/// .confirmations(0usize)
|
||||
/// .send()
|
||||
/// .await?;
|
||||
/// println!("{}", contract.address());
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
pub struct ContractFactory<'a, P, S> {
|
||||
client: &'a Client<P, S>,
|
||||
abi: &'a Abi,
|
||||
|
@ -79,8 +130,10 @@ where
|
|||
S: Signer,
|
||||
P: JsonRpcClient,
|
||||
{
|
||||
/// Instantiate a new contract factory
|
||||
pub fn new(client: &'a Client<P, S>, abi: &'a Abi, bytecode: &'a Bytes) -> Self {
|
||||
/// Creates a factory for deployment of the Contract with bytecode, and the
|
||||
/// constructor defined in the abi. The client will be used to send any deployment
|
||||
/// transaction.
|
||||
pub fn new(abi: &'a Abi, bytecode: &'a Bytes, client: &'a Client<P, S>) -> Self {
|
||||
Self {
|
||||
client,
|
||||
abi,
|
||||
|
@ -88,8 +141,14 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
/// Deploys an instance of the contract with the provider constructor arguments
|
||||
/// and returns the contract's instance
|
||||
/// Constructs the deployment transaction based on the provided constructor
|
||||
/// arguments and returns a `Deployer` instance. You must call `send()` in order
|
||||
/// to actually deploy the contract.
|
||||
///
|
||||
/// Notes:
|
||||
/// 1. If there are no constructor arguments, you should pass `()` as the argument.
|
||||
/// 1. The default poll duration is 7 seconds.
|
||||
/// 1. The default number of confirmations is 1 block.
|
||||
pub fn deploy<T: Tokenize>(
|
||||
&self,
|
||||
constructor_args: T,
|
||||
|
|
|
@ -1,24 +1,28 @@
|
|||
mod contract;
|
||||
pub use contract::Contract;
|
||||
|
||||
mod event;
|
||||
pub use event::Event;
|
||||
|
||||
mod call;
|
||||
pub use call::{ContractCall, ContractError};
|
||||
pub use call::ContractError;
|
||||
|
||||
mod factory;
|
||||
pub use factory::ContractFactory;
|
||||
|
||||
mod event;
|
||||
|
||||
/// This module exposes low lever builder structures which are only consumed by the
|
||||
/// type-safe ABI bindings generators.
|
||||
pub mod builders {
|
||||
pub use super::call::ContractCall;
|
||||
pub use super::event::Event;
|
||||
pub use super::factory::Deployer;
|
||||
}
|
||||
|
||||
#[cfg(feature = "abigen")]
|
||||
pub use ethers_contract_abigen::Abigen;
|
||||
|
||||
#[cfg(feature = "abigen")]
|
||||
pub use ethers_contract_derive::abigen;
|
||||
|
||||
// re-export for convenience
|
||||
pub use ethers_core::abi;
|
||||
pub use ethers_core::types;
|
||||
pub use ethers_providers as providers;
|
||||
pub use ethers_signers as signers;
|
||||
// Hide the Lazy re-export, it's just for convenience
|
||||
#[doc(hidden)]
|
||||
pub use once_cell::sync::Lazy;
|
||||
|
|
|
@ -2,7 +2,7 @@ use ethers_contract::ContractFactory;
|
|||
use ethers_core::{
|
||||
abi::{Detokenize, InvalidOutputType, Token},
|
||||
types::{Address, H256},
|
||||
utils::{GanacheBuilder, Solc},
|
||||
utils::{Ganache, Solc},
|
||||
};
|
||||
use ethers_providers::{Http, Provider};
|
||||
use ethers_signers::Wallet;
|
||||
|
@ -19,7 +19,7 @@ async fn deploy_and_call_contract() {
|
|||
// launch ganache
|
||||
let port = 8546u64;
|
||||
let url = format!("http://localhost:{}", port).to_string();
|
||||
let _ganache = GanacheBuilder::new().port(port)
|
||||
let _ganache = Ganache::new().port(port)
|
||||
.mnemonic("abstract vacuum mammal awkward pudding scene penalty purchase dinner depart evoke puzzle")
|
||||
.spawn();
|
||||
|
||||
|
@ -42,7 +42,7 @@ async fn deploy_and_call_contract() {
|
|||
let client2 = wallet2.connect(provider);
|
||||
|
||||
// create a factory which will be used to deploy instances of the contract
|
||||
let factory = ContractFactory::new(&client, &contract.abi, &contract.bytecode);
|
||||
let factory = ContractFactory::new(&contract.abi, &contract.bytecode, &client);
|
||||
|
||||
// `send` consumes the deployer so it must be cloned for later re-use
|
||||
// (practically it's not expected that you'll need to deploy multiple instances of
|
||||
|
|
Loading…
Reference in New Issue