ABI Encoder v2 + ABI Spec v6.6 (#17)
* feat(core): update ethabi and enable more Toeknize impls * feat(contract/abigen): implement simple AbiEncoderV2 * tests(ethers): add abigen example * fix(core): fix abi tests * chore: make clippy happy
This commit is contained in:
parent
5629c1f25e
commit
570b45eb10
|
@ -234,8 +234,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ethabi"
|
name = "ethabi"
|
||||||
version = "12.0.0"
|
version = "12.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/gakonst/ethabi#bd8214a5897f323b4832cd56430eb74bc6e06cda"
|
||||||
checksum = "052a565e3de82944527d6d10a465697e6bb92476b772ca7141080c901f6a63c6"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ethereum-types",
|
"ethereum-types",
|
||||||
"rustc-hex",
|
"rustc-hex",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use super::{types, util, Context};
|
use super::{types, util, Context};
|
||||||
use ethers_core::{
|
use ethers_core::{
|
||||||
abi::{Function, FunctionExt, Param},
|
abi::{Function, FunctionExt, Param, StateMutability},
|
||||||
types::Selector,
|
types::Selector,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -40,7 +40,11 @@ fn expand_function(function: &Function, alias: Option<Ident>) -> Result<TokenStr
|
||||||
|
|
||||||
let outputs = expand_fn_outputs(&function.outputs)?;
|
let outputs = expand_fn_outputs(&function.outputs)?;
|
||||||
|
|
||||||
let result = if function.constant {
|
let is_mutable = matches!(
|
||||||
|
function.state_mutability,
|
||||||
|
StateMutability::Nonpayable | StateMutability::Payable
|
||||||
|
);
|
||||||
|
let result = if !is_mutable {
|
||||||
quote! { ContractCall<'a, P, S, #outputs> }
|
quote! { ContractCall<'a, P, S, #outputs> }
|
||||||
} else {
|
} else {
|
||||||
quote! { ContractCall<'a, P, S, H256> }
|
quote! { ContractCall<'a, P, S, H256> }
|
||||||
|
|
|
@ -43,7 +43,16 @@ pub(crate) fn expand(kind: &ParamType) -> Result<TokenStream> {
|
||||||
let size = Literal::usize_unsuffixed(*n);
|
let size = Literal::usize_unsuffixed(*n);
|
||||||
Ok(quote! { [#inner; #size] })
|
Ok(quote! { [#inner; #size] })
|
||||||
}
|
}
|
||||||
// TODO: Implement abiencoder v2
|
ParamType::Tuple(members) => {
|
||||||
ParamType::Tuple(_) => Err(anyhow!("ABIEncoderV2 is currently not supported")),
|
if members.is_empty() {
|
||||||
|
return Err(anyhow!("Tuple must have at least 1 member"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let members = members
|
||||||
|
.iter()
|
||||||
|
.map(|member| expand(member))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(quote! { (#(#members,)*) })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,10 @@ use std::{collections::HashMap, fs::File, io::Write, path::Path};
|
||||||
|
|
||||||
/// Builder struct for generating type-safe bindings from a contract's ABI
|
/// Builder struct for generating type-safe bindings from a contract's ABI
|
||||||
///
|
///
|
||||||
|
/// Note: Your contract's ABI must contain the `stateMutability` field. This is
|
||||||
|
/// [still not supported by Vyper](https://github.com/vyperlang/vyper/issues/1931), so you must adjust your ABIs and replace
|
||||||
|
/// `constant` functions with `view` or `pure`.
|
||||||
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
/// Running the command below will generate a file called `token.rs` containing the
|
/// Running the command below will generate a file called `token.rs` containing the
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
use crate::spanned::{ParseInner, Spanned};
|
use crate::spanned::{ParseInner, Spanned};
|
||||||
|
|
||||||
use ethers_contract_abigen::Abigen;
|
use ethers_contract_abigen::Abigen;
|
||||||
use ethers_core::abi::{Function, FunctionExt, Param};
|
use ethers_core::abi::{Function, FunctionExt, Param, StateMutability};
|
||||||
|
|
||||||
use proc_macro2::{Span, TokenStream as TokenStream2};
|
use proc_macro2::{Span, TokenStream as TokenStream2};
|
||||||
use quote::ToTokens;
|
use quote::ToTokens;
|
||||||
|
@ -180,7 +180,7 @@ impl Parse for Method {
|
||||||
// NOTE: The output types and const-ness of the function do not
|
// NOTE: The output types and const-ness of the function do not
|
||||||
// affect its signature.
|
// affect its signature.
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
constant: false,
|
state_mutability: StateMutability::Nonpayable,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let signature = function.abi_signature();
|
let signature = function.abi_signature();
|
||||||
|
|
|
@ -8,7 +8,7 @@ edition = "2018"
|
||||||
# ethereum related
|
# ethereum related
|
||||||
ethereum-types = { version = "0.9.2", default-features = false, features = ["serialize"] }
|
ethereum-types = { version = "0.9.2", default-features = false, features = ["serialize"] }
|
||||||
rlp = { version = "0.4.5", default-features = false }
|
rlp = { version = "0.4.5", default-features = false }
|
||||||
ethabi = { version = "12.0.0", default-features = false, optional = true }
|
ethabi = { git = "https://github.com/gakonst/ethabi", version = "12.0.0", default-features = false, optional = true }
|
||||||
|
|
||||||
# crypto
|
# crypto
|
||||||
secp256k1 = { package = "libsecp256k1", version = "0.3.5" }
|
secp256k1 = { package = "libsecp256k1", version = "0.3.5" }
|
||||||
|
|
|
@ -63,17 +63,20 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn format_function_signature() {
|
fn format_function_signature() {
|
||||||
for (f, expected) in &[
|
for (f, expected) in &[
|
||||||
(r#"{"name":"foo","inputs":[],"outputs":[]}"#, "foo()"),
|
|
||||||
(
|
(
|
||||||
r#"{"name":"bar","inputs":[{"name":"a","type":"uint256"},{"name":"b","type":"bool"}],"outputs":[]}"#,
|
r#"{"name":"foo","inputs":[],"outputs":[], "stateMutability": "nonpayable"}"#,
|
||||||
|
"foo()",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"name":"bar","inputs":[{"name":"a","type":"uint256"},{"name":"b","type":"bool"}],"outputs":[], "stateMutability": "nonpayable"}"#,
|
||||||
"bar(uint256,bool)",
|
"bar(uint256,bool)",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r#"{"name":"baz","inputs":[{"name":"a","type":"uint256"}],"outputs":[{"name":"b","type":"bool"}]}"#,
|
r#"{"name":"baz","inputs":[{"name":"a","type":"uint256"}],"outputs":[{"name":"b","type":"bool"}], "stateMutability": "nonpayable"}"#,
|
||||||
"baz(uint256)",
|
"baz(uint256)",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r#"{"name":"bax","inputs":[],"outputs":[{"name":"a","type":"uint256"},{"name":"b","type":"bool"}]}"#,
|
r#"{"name":"bax","inputs":[],"outputs":[{"name":"a","type":"uint256"},{"name":"b","type":"bool"}], "stateMutability": "nonpayable"}"#,
|
||||||
"bax()",
|
"bax()",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
|
|
|
@ -21,6 +21,15 @@ pub trait Detokenize {
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Detokenize for () {
|
||||||
|
fn from_tokens(_: Vec<Token>) -> std::result::Result<Self, InvalidOutputType>
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T: Tokenizable> Detokenize for T {
|
impl<T: Tokenizable> Detokenize for T {
|
||||||
fn from_tokens(mut tokens: Vec<Token>) -> Result<Self, InvalidOutputType> {
|
fn from_tokens(mut tokens: Vec<Token>) -> Result<Self, InvalidOutputType> {
|
||||||
if tokens.len() != 1 {
|
if tokens.len() != 1 {
|
||||||
|
@ -69,17 +78,17 @@ impl_output!(2, A, B,);
|
||||||
impl_output!(3, A, B, C,);
|
impl_output!(3, A, B, C,);
|
||||||
impl_output!(4, A, B, C, D,);
|
impl_output!(4, A, B, C, D,);
|
||||||
impl_output!(5, A, B, C, D, E,);
|
impl_output!(5, A, B, C, D, E,);
|
||||||
// impl_output!(6, A, B, C, D, E, F,);
|
impl_output!(6, A, B, C, D, E, F,);
|
||||||
// impl_output!(7, A, B, C, D, E, F, G,);
|
impl_output!(7, A, B, C, D, E, F, G,);
|
||||||
// impl_output!(8, A, B, C, D, E, F, G, H,);
|
impl_output!(8, A, B, C, D, E, F, G, H,);
|
||||||
// impl_output!(9, A, B, C, D, E, F, G, H, I,);
|
impl_output!(9, A, B, C, D, E, F, G, H, I,);
|
||||||
// impl_output!(10, A, B, C, D, E, F, G, H, I, J,);
|
impl_output!(10, A, B, C, D, E, F, G, H, I, J,);
|
||||||
// impl_output!(11, A, B, C, D, E, F, G, H, I, J, K,);
|
impl_output!(11, A, B, C, D, E, F, G, H, I, J, K,);
|
||||||
// impl_output!(12, A, B, C, D, E, F, G, H, I, J, K, L,);
|
impl_output!(12, A, B, C, D, E, F, G, H, I, J, K, L,);
|
||||||
// impl_output!(13, A, B, C, D, E, F, G, H, I, J, K, L, M,);
|
impl_output!(13, A, B, C, D, E, F, G, H, I, J, K, L, M,);
|
||||||
// impl_output!(14, A, B, C, D, E, F, G, H, I, J, K, L, M, N,);
|
impl_output!(14, A, B, C, D, E, F, G, H, I, J, K, L, M, N,);
|
||||||
// impl_output!(15, A, B, C, D, E, F, G, H, I, J, K, L, M, N, O,);
|
impl_output!(15, A, B, C, D, E, F, G, H, I, J, K, L, M, N, O,);
|
||||||
// impl_output!(16, A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P,);
|
impl_output!(16, A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P,);
|
||||||
|
|
||||||
/// Tokens conversion trait
|
/// Tokens conversion trait
|
||||||
pub trait Tokenize {
|
pub trait Tokenize {
|
||||||
|
@ -128,17 +137,15 @@ impl_tokens!(A:0, B:1, C:2, D:3, );
|
||||||
impl_tokens!(A:0, B:1, C:2, D:3, E:4, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, );
|
||||||
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, );
|
||||||
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, );
|
||||||
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, );
|
||||||
// Commented out macros to reduce codegen time. Re-enable if needed.
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, N:13, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, N:13, O:14, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, N:13, );
|
impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, N:13, O:14, P:15, );
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, N:13, O:14, );
|
|
||||||
// impl_tokens!(A:0, B:1, C:2, D:3, E:4, F:5, G:6, H:7, I:8, J:9, K:10, L:11, M:12, N:13, O:14, P:15, );
|
|
||||||
|
|
||||||
/// Simplified output type for single value.
|
/// Simplified output type for single value.
|
||||||
pub trait Tokenizable {
|
pub trait Tokenizable {
|
||||||
|
|
|
@ -85,8 +85,8 @@ impl Solc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the contracts and returns a hashmap for each named contract
|
/// Gets the ABI for the contracts
|
||||||
pub fn build(self) -> Result<HashMap<String, CompiledContract>> {
|
pub fn build_raw(self) -> Result<HashMap<String, CompiledContractStr>> {
|
||||||
let mut command = Command::new(SOLC);
|
let mut command = Command::new(SOLC);
|
||||||
|
|
||||||
command
|
command
|
||||||
|
@ -110,25 +110,46 @@ impl Solc {
|
||||||
// Deserialize the output
|
// Deserialize the output
|
||||||
let output: SolcOutput = serde_json::from_slice(&command.stdout)?;
|
let output: SolcOutput = serde_json::from_slice(&command.stdout)?;
|
||||||
|
|
||||||
// Get the data in the correct format
|
// remove the semi-colon and the name
|
||||||
let contracts = output
|
let contracts = output
|
||||||
.contracts
|
.contracts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, contract)| {
|
.map(|(name, contract)| {
|
||||||
let abi = serde_json::from_str(&contract.abi)
|
|
||||||
.expect("could not parse `solc` abi, this should never happen");
|
|
||||||
|
|
||||||
let bytecode = contract
|
|
||||||
.bin
|
|
||||||
.from_hex::<Vec<u8>>()
|
|
||||||
.expect("solc did not produce valid bytecode")
|
|
||||||
.into();
|
|
||||||
|
|
||||||
let name = name
|
let name = name
|
||||||
.rsplit(':')
|
.rsplit(':')
|
||||||
.next()
|
.next()
|
||||||
.expect("could not strip fname")
|
.expect("could not strip fname")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
CompiledContractStr {
|
||||||
|
abi: contract.abi,
|
||||||
|
bin: contract.bin,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(contracts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the contracts and returns a hashmap for each named contract
|
||||||
|
pub fn build(self) -> Result<HashMap<String, CompiledContract>> {
|
||||||
|
// Build, and then get the data in the correct format
|
||||||
|
let contracts = self
|
||||||
|
.build_raw()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, contract)| {
|
||||||
|
// parse the ABI
|
||||||
|
let abi = serde_json::from_str(&contract.abi)
|
||||||
|
.expect("could not parse `solc` abi, this should never happen");
|
||||||
|
|
||||||
|
// parse the bytecode
|
||||||
|
let bytecode = contract
|
||||||
|
.bin
|
||||||
|
.from_hex::<Vec<u8>>()
|
||||||
|
.expect("solc did not produce valid bytecode")
|
||||||
|
.into();
|
||||||
(name, CompiledContract { abi, bytecode })
|
(name, CompiledContract { abi, bytecode })
|
||||||
})
|
})
|
||||||
.collect::<HashMap<String, CompiledContract>>();
|
.collect::<HashMap<String, CompiledContract>>();
|
||||||
|
@ -212,8 +233,10 @@ struct SolcOutput {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
// Helper struct for deserializing the solc string outputs
|
/// Helper struct for deserializing the solc string outputs
|
||||||
struct CompiledContractStr {
|
pub struct CompiledContractStr {
|
||||||
abi: String,
|
/// The contract's raw ABI
|
||||||
bin: String,
|
pub abi: String,
|
||||||
|
/// The contract's bytecode in hex
|
||||||
|
pub bin: String,
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
use ethers::{contract::Abigen, utils::Solc};
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let mut args = std::env::args();
|
||||||
|
args.next().unwrap(); // skip program name
|
||||||
|
|
||||||
|
let contract_name = args.next().unwrap();
|
||||||
|
let contract: String = args.next().unwrap();
|
||||||
|
|
||||||
|
println!("Generating bindings for {}\n", contract);
|
||||||
|
|
||||||
|
// compile it if needed
|
||||||
|
let abi = if contract.ends_with(".sol") {
|
||||||
|
let contracts = Solc::new(&contract).build_raw()?;
|
||||||
|
contracts.get(&contract_name).unwrap().abi.clone()
|
||||||
|
} else {
|
||||||
|
contract
|
||||||
|
};
|
||||||
|
|
||||||
|
let bindings = Abigen::new(&contract_name, abi)?.generate()?;
|
||||||
|
|
||||||
|
// print to stdout if no output arg is given
|
||||||
|
if let Some(output_path) = args.next() {
|
||||||
|
bindings.write_to_file(&output_path)?;
|
||||||
|
} else {
|
||||||
|
bindings.write(std::io::stdout())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ use std::convert::TryFrom;
|
||||||
// Generate the type-safe contract bindings by providing the ABI
|
// Generate the type-safe contract bindings by providing the ABI
|
||||||
abigen!(
|
abigen!(
|
||||||
SimpleContract,
|
SimpleContract,
|
||||||
r#"[{"inputs":[{"internalType":"string","name":"value","type":"string"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"author","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","constant": true, "type":"function"},{"inputs":[{"internalType":"string","name":"value","type":"string"}],"name":"setValue","outputs":[],"stateMutability":"nonpayable","type":"function"}]"#,
|
r#"[{"inputs":[{"internalType":"string","name":"value","type":"string"}],"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"author","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":[{"internalType":"string","name":"value","type":"string"}],"name":"setValue","outputs":[],"stateMutability":"nonpayable","type":"function"}]"#,
|
||||||
event_derives(serde::Deserialize, serde::Serialize)
|
event_derives(serde::Deserialize, serde::Serialize)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -10,10 +10,12 @@ abigen!(
|
||||||
Comptroller,
|
Comptroller,
|
||||||
"etherscan:0x3d9819210a31b4961b30ef54be2aed79b9c9cd3b"
|
"etherscan:0x3d9819210a31b4961b30ef54be2aed79b9c9cd3b"
|
||||||
);
|
);
|
||||||
abigen!(
|
|
||||||
Curve,
|
// https://github.com/vyperlang/vyper/issues/1931
|
||||||
"etherscan:0xa2b47e3d5c44877cca798226b7b8118f9bfb7a56"
|
// abigen!(
|
||||||
);
|
// Curve,
|
||||||
|
// "etherscan:0xa2b47e3d5c44877cca798226b7b8118f9bfb7a56"
|
||||||
|
// );
|
||||||
abigen!(
|
abigen!(
|
||||||
UmaAdmin,
|
UmaAdmin,
|
||||||
"etherscan:0x4E6CCB1dA3C7844887F9A5aF4e8450d9fd90317A"
|
"etherscan:0x4E6CCB1dA3C7844887F9A5aF4e8450d9fd90317A"
|
||||||
|
@ -28,7 +30,7 @@ abigen!(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Abi Encoder v2 not yet supported :(
|
// // Abi Encoder v2 is still buggy
|
||||||
// abigen!(
|
// abigen!(
|
||||||
// DyDxLimitOrders,
|
// DyDxLimitOrders,
|
||||||
// "etherscan:0xDEf136D9884528e1EB302f39457af0E4d3AD24EB"
|
// "etherscan:0xDEf136D9884528e1EB302f39457af0E4d3AD24EB"
|
||||||
|
|
Loading…
Reference in New Issue