refactor: make human readable abi parsing more robust (#225)
* refactor: event parser * refactor: function parser * refactor: add constructor parser * refactor: replace parsers * style: extract event arg parsing into separate method * fix: add missing returns statement * chore(clippy): make clippy happy * fix: support unnamed event argument parsing
This commit is contained in:
parent
08cacfeee8
commit
f599ae66f4
|
@ -1,9 +1,11 @@
|
||||||
use super::{
|
|
||||||
param_type::Reader, Abi, Event, EventParam, Function, Param, ParamType, StateMutability,
|
|
||||||
};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
param_type::Reader, Abi, Constructor, Event, EventParam, Function, Param, StateMutability,
|
||||||
|
};
|
||||||
|
|
||||||
/// Parses a "human readable abi" string vector
|
/// Parses a "human readable abi" string vector
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
|
@ -22,140 +24,290 @@ pub fn parse(input: &[&str]) -> Result<Abi, ParseError> {
|
||||||
fallback: false,
|
fallback: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for line in input {
|
for mut line in input.iter().map(|s| escape_quotes(s)) {
|
||||||
if line.contains("function") {
|
line = line.trim_start();
|
||||||
|
if line.starts_with("function") {
|
||||||
let function = parse_function(&line)?;
|
let function = parse_function(&line)?;
|
||||||
abi.functions
|
abi.functions
|
||||||
.entry(function.name.clone())
|
.entry(function.name.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(function);
|
.push(function);
|
||||||
} else if line.contains("event") {
|
} else if line.starts_with("event") {
|
||||||
let event = parse_event(&line)?;
|
let event = parse_event(line)?;
|
||||||
abi.events
|
abi.events
|
||||||
.entry(event.name.clone())
|
.entry(event.name.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(event);
|
.push(event);
|
||||||
} else if line.starts_with("struct") {
|
} else if line.starts_with("constructor") {
|
||||||
panic!("Got tuple");
|
abi.constructor = Some(parse_constructor(line)?);
|
||||||
} else {
|
} else {
|
||||||
panic!("unknown sig")
|
return Err(ParseError::ParseError(super::Error::InvalidData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(abi)
|
Ok(abi)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_event(event: &str) -> Result<Event, ParseError> {
|
/// Parses an identifier like event or function name
|
||||||
let split: Vec<&str> = event.split("event ").collect();
|
fn parse_identifier(input: &mut &str) -> Result<String, ParseError> {
|
||||||
let split: Vec<&str> = split[1].split('(').collect();
|
let mut chars = input.trim_start().chars();
|
||||||
let name = split[0].trim_end();
|
let mut name = String::new();
|
||||||
let rest = split[1];
|
let c = chars
|
||||||
|
.next()
|
||||||
let args = rest.replace(")", "");
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
let anonymous = rest.contains("anonymous");
|
if is_first_ident_char(c) {
|
||||||
|
name.push(c);
|
||||||
let inputs = if args.contains(',') {
|
loop {
|
||||||
let args: Vec<&str> = args.split(", ").collect();
|
match chars.clone().next() {
|
||||||
args.iter()
|
Some(c) if is_ident_char(c) => {
|
||||||
.map(|arg| parse_event_arg(arg))
|
chars.next();
|
||||||
.collect::<Result<Vec<EventParam>, _>>()?
|
name.push(c);
|
||||||
} else {
|
}
|
||||||
vec![]
|
_ => break,
|
||||||
};
|
}
|
||||||
|
}
|
||||||
Ok(Event {
|
}
|
||||||
name: name.to_owned(),
|
*input = chars.as_str();
|
||||||
anonymous,
|
Ok(name)
|
||||||
inputs,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses an event's argument as indexed if neded
|
/// Parses a solidity event declaration from `event <name> (args*) anonymous?`
|
||||||
fn parse_event_arg(param: &str) -> Result<EventParam, ParseError> {
|
fn parse_event(mut event: &str) -> Result<Event, ParseError> {
|
||||||
let tokens: Vec<&str> = param.split(' ').collect();
|
event = event.trim();
|
||||||
let kind: ParamType = Reader::read(tokens[0])?;
|
if !event.starts_with("event ") {
|
||||||
let (name, indexed) = if tokens.len() == 2 {
|
return Err(ParseError::ParseError(super::Error::InvalidData));
|
||||||
(tokens[1], false)
|
}
|
||||||
} else {
|
event = &event[5..];
|
||||||
(tokens[2], true)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
let name = parse_identifier(&mut event)?;
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ParseError::ParseError(super::Error::InvalidName(
|
||||||
|
event.to_owned(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chars = event.chars();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match chars.next() {
|
||||||
|
None => return Err(ParseError::ParseError(super::Error::InvalidData)),
|
||||||
|
Some('(') => {
|
||||||
|
event = chars.as_str().trim();
|
||||||
|
let mut anonymous = false;
|
||||||
|
if event.ends_with("anonymous") {
|
||||||
|
anonymous = true;
|
||||||
|
event = event[..event.len() - 9].trim_end();
|
||||||
|
}
|
||||||
|
event = event
|
||||||
|
.trim()
|
||||||
|
.strip_suffix(')')
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
|
|
||||||
|
let inputs = if event.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
event
|
||||||
|
.split(',')
|
||||||
|
.map(parse_event_arg)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
return Ok(Event {
|
||||||
|
name,
|
||||||
|
inputs,
|
||||||
|
anonymous,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(' ') | Some('\t') => {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(ParseError::ParseError(super::Error::InvalidData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single event param
|
||||||
|
fn parse_event_arg(input: &str) -> Result<EventParam, ParseError> {
|
||||||
|
let mut iter = input.trim().rsplitn(3, is_whitespace);
|
||||||
|
let mut indexed = false;
|
||||||
|
let mut name = iter
|
||||||
|
.next()
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
|
|
||||||
|
if let Some(mid) = iter.next() {
|
||||||
|
let kind;
|
||||||
|
if let Some(ty) = iter.next() {
|
||||||
|
if mid != "indexed" {
|
||||||
|
return Err(ParseError::ParseError(super::Error::InvalidData));
|
||||||
|
}
|
||||||
|
indexed = true;
|
||||||
|
kind = Reader::read(ty)?;
|
||||||
|
} else {
|
||||||
|
if name == "indexed" {
|
||||||
|
indexed = true;
|
||||||
|
name = "";
|
||||||
|
}
|
||||||
|
kind = Reader::read(mid)?;
|
||||||
|
}
|
||||||
Ok(EventParam {
|
Ok(EventParam {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
kind,
|
kind,
|
||||||
indexed,
|
indexed,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
Ok(EventParam {
|
||||||
|
name: "".to_owned(),
|
||||||
|
indexed,
|
||||||
|
kind: Reader::read(name)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_function(fn_string: &str) -> Result<Function, ParseError> {
|
fn parse_function(mut input: &str) -> Result<Function, ParseError> {
|
||||||
let fn_string = fn_string.to_owned();
|
input = input.trim();
|
||||||
let delim = if fn_string.starts_with("function ") {
|
if !input.starts_with("function ") {
|
||||||
"function "
|
return Err(ParseError::ParseError(super::Error::InvalidData));
|
||||||
|
}
|
||||||
|
input = &input[8..];
|
||||||
|
let name = parse_identifier(&mut input)?;
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ParseError::ParseError(super::Error::InvalidName(
|
||||||
|
input.to_owned(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut iter = input.split(" returns");
|
||||||
|
|
||||||
|
let parens = iter
|
||||||
|
.next()
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?
|
||||||
|
.trim_end();
|
||||||
|
|
||||||
|
let mut parens_iter = parens.rsplitn(2, ')');
|
||||||
|
let mut modifiers = parens_iter.next();
|
||||||
|
|
||||||
|
let input_params = if let Some(args) = parens_iter.next() {
|
||||||
|
args
|
||||||
} else {
|
} else {
|
||||||
" "
|
modifiers
|
||||||
};
|
.take()
|
||||||
let split: Vec<&str> = fn_string.split(delim).collect();
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?
|
||||||
let split: Vec<&str> = split[1].split('(').collect();
|
}
|
||||||
|
.trim_start()
|
||||||
|
.strip_prefix('(')
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
|
|
||||||
// function name is the first char
|
let inputs = input_params
|
||||||
let fn_name = split[0];
|
.split(',')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(parse_param)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
// internal args
|
let outputs = if let Some(params) = iter.next() {
|
||||||
let args: Vec<&str> = split[1].split(')').collect();
|
let params = params
|
||||||
let args: Vec<&str> = args[0].split(", ").collect();
|
.trim()
|
||||||
|
.strip_prefix('(')
|
||||||
let inputs = args
|
.and_then(|s| s.strip_suffix(')'))
|
||||||
.into_iter()
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
.filter(|x| !x.is_empty())
|
params
|
||||||
.filter(|x| !x.contains("returns"))
|
.split(',')
|
||||||
.map(|x| parse_param(x))
|
.filter(|s| !s.is_empty())
|
||||||
.collect::<Result<Vec<Param>, _>>()?;
|
.map(parse_param)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
// return value
|
|
||||||
let outputs: Vec<Param> = if split.len() > 2 {
|
|
||||||
let ret = split[2].strip_suffix(")").expect("no right paren");
|
|
||||||
let ret: Vec<&str> = ret.split(", ").collect();
|
|
||||||
|
|
||||||
ret.into_iter()
|
|
||||||
// remove modifiers etc
|
|
||||||
.filter(|x| !x.is_empty())
|
|
||||||
.map(|x| parse_param(x))
|
|
||||||
.collect::<Result<Vec<Param>, _>>()?
|
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let state_mutability = modifiers.map(detect_state_mutability).unwrap_or_default();
|
||||||
|
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
Ok(Function {
|
Ok(Function {
|
||||||
name: fn_name.to_owned(),
|
name,
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
// this doesn't really matter
|
state_mutability,
|
||||||
state_mutability: StateMutability::NonPayable,
|
|
||||||
constant: false,
|
constant: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// address x
|
fn parse_constructor(mut input: &str) -> Result<Constructor, ParseError> {
|
||||||
fn parse_param(param: &str) -> Result<Param, ParseError> {
|
input = input.trim();
|
||||||
let mut param = param
|
if !input.starts_with("constructor") {
|
||||||
.split(' ')
|
return Err(ParseError::ParseError(super::Error::InvalidData));
|
||||||
.filter(|x| !x.contains("memory") || !x.contains("calldata"));
|
|
||||||
|
|
||||||
let kind = param.next().ok_or(ParseError::Kind)?;
|
|
||||||
let kind: ParamType = Reader::read(kind).unwrap();
|
|
||||||
|
|
||||||
// strip memory/calldata from the name
|
|
||||||
// e.g. uint256[] memory x
|
|
||||||
let mut name = param.next().unwrap_or_default();
|
|
||||||
if name == "memory" || name == "calldata" {
|
|
||||||
name = param.next().unwrap_or_default();
|
|
||||||
}
|
}
|
||||||
|
input = input[11..]
|
||||||
|
.trim_start()
|
||||||
|
.strip_prefix('(')
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
|
|
||||||
|
let params = input
|
||||||
|
.rsplitn(2, ')')
|
||||||
|
.last()
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
|
|
||||||
|
let inputs = params
|
||||||
|
.split(',')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(parse_param)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(Constructor { inputs })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_state_mutability(s: &str) -> StateMutability {
|
||||||
|
if s.contains("pure") {
|
||||||
|
StateMutability::Pure
|
||||||
|
} else if s.contains("view") {
|
||||||
|
StateMutability::View
|
||||||
|
} else if s.contains("payable") {
|
||||||
|
StateMutability::Payable
|
||||||
|
} else {
|
||||||
|
StateMutability::NonPayable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_param(param: &str) -> Result<Param, ParseError> {
|
||||||
|
let mut iter = param.trim().rsplitn(3, is_whitespace);
|
||||||
|
|
||||||
|
let name = iter
|
||||||
|
.next()
|
||||||
|
.ok_or(ParseError::ParseError(super::Error::InvalidData))?;
|
||||||
|
|
||||||
|
if let Some(ty) = iter.last() {
|
||||||
|
if name == "memory" || name == "calldata" {
|
||||||
|
Ok(Param {
|
||||||
|
name: "".to_owned(),
|
||||||
|
kind: Reader::read(ty)?,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
Ok(Param {
|
Ok(Param {
|
||||||
name: name.to_owned(),
|
name: name.to_owned(),
|
||||||
kind,
|
kind: Reader::read(ty)?,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(Param {
|
||||||
|
name: "".to_owned(),
|
||||||
|
kind: Reader::read(name)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_first_ident_char(c: char) -> bool {
|
||||||
|
matches!(c, 'a'..='z' | 'A'..='Z' | '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ident_char(c: char) -> bool {
|
||||||
|
matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_whitespace(c: char) -> bool {
|
||||||
|
matches!(c, ' ' | '\t')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_quotes(input: &str) -> &str {
|
||||||
|
input.trim_matches(is_whitespace).trim_matches('\"')
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
|
@ -170,6 +322,7 @@ pub enum ParseError {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::abi::ParamType;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_approve() {
|
fn parses_approve() {
|
||||||
|
@ -207,10 +360,31 @@ mod tests {
|
||||||
assert!(parsed.outputs.is_empty());
|
assert!(parsed.outputs.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_function_payable() {
|
||||||
|
let fn_str = "function foo() public payable";
|
||||||
|
let parsed = parse_function(fn_str).unwrap();
|
||||||
|
assert_eq!(parsed.state_mutability, StateMutability::Payable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_function_view() {
|
||||||
|
let fn_str = "function foo() external view";
|
||||||
|
let parsed = parse_function(fn_str).unwrap();
|
||||||
|
assert_eq!(parsed.state_mutability, StateMutability::View);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_function_pure() {
|
||||||
|
let fn_str = "function foo() pure";
|
||||||
|
let parsed = parse_function(fn_str).unwrap();
|
||||||
|
assert_eq!(parsed.state_mutability, StateMutability::Pure);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_event() {
|
fn parses_event() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_event("event Foo (address indexed x, uint y, bytes32[] z)").unwrap(),
|
parse_event(&mut "event Foo (address indexed x, uint y, bytes32[] z)").unwrap(),
|
||||||
Event {
|
Event {
|
||||||
anonymous: false,
|
anonymous: false,
|
||||||
name: "Foo".to_owned(),
|
name: "Foo".to_owned(),
|
||||||
|
@ -218,7 +392,7 @@ mod tests {
|
||||||
EventParam {
|
EventParam {
|
||||||
name: "x".to_owned(),
|
name: "x".to_owned(),
|
||||||
kind: ParamType::Address,
|
kind: ParamType::Address,
|
||||||
indexed: true
|
indexed: true,
|
||||||
},
|
},
|
||||||
EventParam {
|
EventParam {
|
||||||
name: "y".to_owned(),
|
name: "y".to_owned(),
|
||||||
|
@ -230,7 +404,7 @@ mod tests {
|
||||||
kind: ParamType::Array(Box::new(ParamType::FixedBytes(32))),
|
kind: ParamType::Array(Box::new(ParamType::FixedBytes(32))),
|
||||||
indexed: false,
|
indexed: false,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -238,7 +412,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_anonymous_event() {
|
fn parses_anonymous_event() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_event("event Foo() anonymous").unwrap(),
|
parse_event(&mut "event Foo() anonymous").unwrap(),
|
||||||
Event {
|
Event {
|
||||||
anonymous: true,
|
anonymous: true,
|
||||||
name: "Foo".to_owned(),
|
name: "Foo".to_owned(),
|
||||||
|
@ -247,6 +421,38 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_unnamed_event() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_event(&mut "event Foo(address)").unwrap(),
|
||||||
|
Event {
|
||||||
|
anonymous: false,
|
||||||
|
name: "Foo".to_owned(),
|
||||||
|
inputs: vec![EventParam {
|
||||||
|
name: "".to_owned(),
|
||||||
|
kind: ParamType::Address,
|
||||||
|
indexed: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_unnamed_indexed_event() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_event(&mut "event Foo(address indexed)").unwrap(),
|
||||||
|
Event {
|
||||||
|
anonymous: false,
|
||||||
|
name: "Foo".to_owned(),
|
||||||
|
inputs: vec![EventParam {
|
||||||
|
name: "".to_owned(),
|
||||||
|
kind: ParamType::Address,
|
||||||
|
indexed: true,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_event_input() {
|
fn parse_event_input() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
@ -254,7 +460,7 @@ mod tests {
|
||||||
EventParam {
|
EventParam {
|
||||||
name: "x".to_owned(),
|
name: "x".to_owned(),
|
||||||
kind: ParamType::Address,
|
kind: ParamType::Address,
|
||||||
indexed: true
|
indexed: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -263,7 +469,7 @@ mod tests {
|
||||||
EventParam {
|
EventParam {
|
||||||
name: "x".to_owned(),
|
name: "x".to_owned(),
|
||||||
kind: ParamType::Address,
|
kind: ParamType::Address,
|
||||||
indexed: false
|
indexed: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -304,7 +510,7 @@ mod tests {
|
||||||
fn can_read_backslashes() {
|
fn can_read_backslashes() {
|
||||||
parse(&[
|
parse(&[
|
||||||
"\"function setValue(string)\"",
|
"\"function setValue(string)\"",
|
||||||
"\"function getValue() external view (string)\"",
|
"\"function getValue() external view returns(string)\"",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ abigen!(
|
||||||
SimpleContract,
|
SimpleContract,
|
||||||
r#"[
|
r#"[
|
||||||
function setValue(string)
|
function setValue(string)
|
||||||
function getValue() external view (string)
|
function getValue() external view returns (string)
|
||||||
event ValueChanged(address indexed author, string oldValue, string newValue)
|
event ValueChanged(address indexed author, string oldValue, string newValue)
|
||||||
]"#,
|
]"#,
|
||||||
event_derives(serde::Deserialize, serde::Serialize)
|
event_derives(serde::Deserialize, serde::Serialize)
|
||||||
|
|
Loading…
Reference in New Issue