feat(addresses): Improve API ergonomics (#148)
This commit is contained in:
parent
2b54eda487
commit
a26773a94c
2 changed files with 131 additions and 54 deletions
|
|
@ -16,6 +16,7 @@ authors = [
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
pallas-crypto = { version = "0.11.0", path = "../pallas-crypto" }
|
pallas-crypto = { version = "0.11.0", path = "../pallas-crypto" }
|
||||||
pallas-codec = { version = "0.11.0", path = "../pallas-codec" }
|
pallas-codec = { version = "0.11.0", path = "../pallas-codec" }
|
||||||
|
pallas-primitives = { version = "0.11.0", path = "../pallas-primitives" }
|
||||||
base58 = "0.2.0"
|
base58 = "0.2.0"
|
||||||
bech32 = "0.8.1"
|
bech32 = "0.8.1"
|
||||||
thiserror = "1.0.31"
|
thiserror = "1.0.31"
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,7 @@ pub enum Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type PaymentKeyHash = Hash<28>;
|
pub type PaymentKeyHash = Hash<28>;
|
||||||
|
|
||||||
pub type StakeKeyHash = Hash<28>;
|
pub type StakeKeyHash = Hash<28>;
|
||||||
|
|
||||||
pub type ScriptHash = Hash<28>;
|
pub type ScriptHash = Hash<28>;
|
||||||
|
|
||||||
pub type Slot = u64;
|
pub type Slot = u64;
|
||||||
|
|
@ -104,23 +102,23 @@ impl Pointer {
|
||||||
/// The payment part of a Shelley address
|
/// The payment part of a Shelley address
|
||||||
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||||
pub enum ShelleyPaymentPart {
|
pub enum ShelleyPaymentPart {
|
||||||
PaymentKey(PaymentKeyHash),
|
Key(PaymentKeyHash),
|
||||||
Script(ScriptHash),
|
Script(ScriptHash),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShelleyPaymentPart {
|
impl ShelleyPaymentPart {
|
||||||
fn payment_key(bytes: &[u8]) -> Result<Self, Error> {
|
pub fn key_hash(hash: Hash<28>) -> Self {
|
||||||
slice_to_hash(bytes).map(ShelleyPaymentPart::PaymentKey)
|
Self::Key(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn script(bytes: &[u8]) -> Result<Self, Error> {
|
pub fn script_hash(hash: Hash<28>) -> Self {
|
||||||
slice_to_hash(bytes).map(ShelleyPaymentPart::Script)
|
Self::Script(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the inner hash of this address part
|
/// Get a reference to the inner hash of this address part
|
||||||
pub fn as_hash(&self) -> &Hash<28> {
|
pub fn as_hash(&self) -> &Hash<28> {
|
||||||
match self {
|
match self {
|
||||||
Self::PaymentKey(x) => x,
|
Self::Key(x) => x,
|
||||||
Self::Script(x) => x,
|
Self::Script(x) => x,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +126,7 @@ impl ShelleyPaymentPart {
|
||||||
/// Encodes this address as a sequence of bytes
|
/// Encodes this address as a sequence of bytes
|
||||||
pub fn to_vec(&self) -> Vec<u8> {
|
pub fn to_vec(&self) -> Vec<u8> {
|
||||||
match self {
|
match self {
|
||||||
Self::PaymentKey(x) => x.to_vec(),
|
Self::Key(x) => x.to_vec(),
|
||||||
Self::Script(x) => x.to_vec(),
|
Self::Script(x) => x.to_vec(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,22 +140,22 @@ impl ShelleyPaymentPart {
|
||||||
/// The delegation part of a Shelley address
|
/// The delegation part of a Shelley address
|
||||||
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||||
pub enum ShelleyDelegationPart {
|
pub enum ShelleyDelegationPart {
|
||||||
StakeKey(StakeKeyHash),
|
Key(StakeKeyHash),
|
||||||
Script(ScriptHash),
|
Script(ScriptHash),
|
||||||
Pointer(Pointer),
|
Pointer(Pointer),
|
||||||
Null,
|
Null,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShelleyDelegationPart {
|
impl ShelleyDelegationPart {
|
||||||
fn stake_key(bytes: &[u8]) -> Result<Self, Error> {
|
pub fn key_hash(hash: Hash<28>) -> Self {
|
||||||
slice_to_hash(bytes).map(Self::StakeKey)
|
Self::Key(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn script(bytes: &[u8]) -> Result<Self, Error> {
|
pub fn script_hash(hash: Hash<28>) -> Self {
|
||||||
slice_to_hash(bytes).map(Self::Script)
|
Self::Script(hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pointer(bytes: &[u8]) -> Result<Self, Error> {
|
pub fn from_pointer(bytes: &[u8]) -> Result<Self, Error> {
|
||||||
let pointer = Pointer::parse(bytes)?;
|
let pointer = Pointer::parse(bytes)?;
|
||||||
Ok(Self::Pointer(pointer))
|
Ok(Self::Pointer(pointer))
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +163,7 @@ impl ShelleyDelegationPart {
|
||||||
/// Get a reference to the inner hash of this address part
|
/// Get a reference to the inner hash of this address part
|
||||||
pub fn as_hash(&self) -> Option<&Hash<28>> {
|
pub fn as_hash(&self) -> Option<&Hash<28>> {
|
||||||
match self {
|
match self {
|
||||||
Self::StakeKey(x) => Some(x),
|
Self::Key(x) => Some(x),
|
||||||
Self::Script(x) => Some(x),
|
Self::Script(x) => Some(x),
|
||||||
Self::Pointer(_) => todo!(),
|
Self::Pointer(_) => todo!(),
|
||||||
Self::Null => todo!(),
|
Self::Null => todo!(),
|
||||||
|
|
@ -174,7 +172,7 @@ impl ShelleyDelegationPart {
|
||||||
|
|
||||||
pub fn to_vec(&self) -> Vec<u8> {
|
pub fn to_vec(&self) -> Vec<u8> {
|
||||||
match self {
|
match self {
|
||||||
Self::StakeKey(x) => x.to_vec(),
|
Self::Key(x) => x.to_vec(),
|
||||||
Self::Script(x) => x.to_vec(),
|
Self::Script(x) => x.to_vec(),
|
||||||
Self::Pointer(x) => x.to_vec(),
|
Self::Pointer(x) => x.to_vec(),
|
||||||
Self::Null => vec![],
|
Self::Null => vec![],
|
||||||
|
|
@ -208,6 +206,16 @@ pub enum Network {
|
||||||
Other(u8),
|
Other(u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<u8> for Network {
|
||||||
|
fn from(id: u8) -> Self {
|
||||||
|
match id {
|
||||||
|
0 => Network::Testnet,
|
||||||
|
1 => Network::Mainnet,
|
||||||
|
x => Network::Other(x),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A decoded Shelley address
|
/// A decoded Shelley address
|
||||||
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
#[derive(Debug, Clone, PartialEq, PartialOrd)]
|
||||||
pub struct ShelleyAddress(Network, ShelleyPaymentPart, ShelleyDelegationPart);
|
pub struct ShelleyAddress(Network, ShelleyPaymentPart, ShelleyDelegationPart);
|
||||||
|
|
@ -260,8 +268,9 @@ macro_rules! parse_shelley_fn {
|
||||||
($name:tt, $payment:tt, pointer) => {
|
($name:tt, $payment:tt, pointer) => {
|
||||||
fn $name(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
fn $name(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
||||||
let net = parse_network(header);
|
let net = parse_network(header);
|
||||||
let p1 = ShelleyPaymentPart::$payment(&payload[0..=27])?;
|
let h1 = slice_to_hash(&payload[0..=27])?;
|
||||||
let p2 = ShelleyDelegationPart::pointer(&payload[28..])?;
|
let p1 = ShelleyPaymentPart::$payment(h1);
|
||||||
|
let p2 = ShelleyDelegationPart::from_pointer(&payload[28..])?;
|
||||||
let addr = ShelleyAddress(net, p1, p2);
|
let addr = ShelleyAddress(net, p1, p2);
|
||||||
|
|
||||||
Ok(addr.into())
|
Ok(addr.into())
|
||||||
|
|
@ -270,8 +279,10 @@ macro_rules! parse_shelley_fn {
|
||||||
($name:tt, $payment:tt, $delegation:tt) => {
|
($name:tt, $payment:tt, $delegation:tt) => {
|
||||||
fn $name(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
fn $name(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
||||||
let net = parse_network(header);
|
let net = parse_network(header);
|
||||||
let p1 = ShelleyPaymentPart::$payment(&payload[0..=27])?;
|
let h1 = slice_to_hash(&payload[0..=27])?;
|
||||||
let p2 = ShelleyDelegationPart::$delegation(&payload[28..=55])?;
|
let p1 = ShelleyPaymentPart::$payment(h1);
|
||||||
|
let h2 = slice_to_hash(&payload[28..=55])?;
|
||||||
|
let p2 = ShelleyDelegationPart::$delegation(h2);
|
||||||
let addr = ShelleyAddress(net, p1, p2);
|
let addr = ShelleyAddress(net, p1, p2);
|
||||||
|
|
||||||
Ok(addr.into())
|
Ok(addr.into())
|
||||||
|
|
@ -280,7 +291,8 @@ macro_rules! parse_shelley_fn {
|
||||||
($name:tt, $payment:tt) => {
|
($name:tt, $payment:tt) => {
|
||||||
fn $name(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
fn $name(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
||||||
let net = parse_network(header);
|
let net = parse_network(header);
|
||||||
let p1 = ShelleyPaymentPart::$payment(&payload[0..=27])?;
|
let h1 = slice_to_hash(&payload[0..=27])?;
|
||||||
|
let p1 = ShelleyPaymentPart::$payment(h1);
|
||||||
let addr = ShelleyAddress(net, p1, ShelleyDelegationPart::Null);
|
let addr = ShelleyAddress(net, p1, ShelleyDelegationPart::Null);
|
||||||
|
|
||||||
Ok(addr.into())
|
Ok(addr.into())
|
||||||
|
|
@ -301,14 +313,14 @@ macro_rules! parse_stake_fn {
|
||||||
}
|
}
|
||||||
|
|
||||||
// types 0-7 are Shelley addresses
|
// types 0-7 are Shelley addresses
|
||||||
parse_shelley_fn!(parse_type_0, payment_key, stake_key);
|
parse_shelley_fn!(parse_type_0, key_hash, key_hash);
|
||||||
parse_shelley_fn!(parse_type_1, script, stake_key);
|
parse_shelley_fn!(parse_type_1, script_hash, key_hash);
|
||||||
parse_shelley_fn!(parse_type_2, payment_key, script);
|
parse_shelley_fn!(parse_type_2, key_hash, script_hash);
|
||||||
parse_shelley_fn!(parse_type_3, script, script);
|
parse_shelley_fn!(parse_type_3, script_hash, script_hash);
|
||||||
parse_shelley_fn!(parse_type_4, payment_key, pointer);
|
parse_shelley_fn!(parse_type_4, key_hash, pointer);
|
||||||
parse_shelley_fn!(parse_type_5, script, pointer);
|
parse_shelley_fn!(parse_type_5, script_hash, pointer);
|
||||||
parse_shelley_fn!(parse_type_6, payment_key);
|
parse_shelley_fn!(parse_type_6, key_hash);
|
||||||
parse_shelley_fn!(parse_type_7, script);
|
parse_shelley_fn!(parse_type_7, script_hash);
|
||||||
|
|
||||||
// type 8 (1000) are Byron addresses
|
// type 8 (1000) are Byron addresses
|
||||||
fn parse_type_8(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
fn parse_type_8(header: u8, payload: &[u8]) -> Result<Address, Error> {
|
||||||
|
|
@ -345,22 +357,6 @@ fn bech32_to_address(bech32: &str) -> Result<Address, Error> {
|
||||||
bytes_to_address(&bytes)
|
bytes_to_address(&bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn address_to_bech32(addr: &Address) -> Result<String, Error> {
|
|
||||||
match addr {
|
|
||||||
Address::Byron(_) => Err(Error::InvalidForByron),
|
|
||||||
Address::Shelley(ref x) => {
|
|
||||||
let hrp = x.hrp()?;
|
|
||||||
let bytes = x.to_vec();
|
|
||||||
encode_bech32(&bytes, hrp)
|
|
||||||
}
|
|
||||||
Address::Stake(ref x) => {
|
|
||||||
let hrp = x.hrp()?;
|
|
||||||
let bytes = x.to_vec();
|
|
||||||
encode_bech32(&bytes, hrp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Network {
|
impl Network {
|
||||||
pub fn is_mainnet(&self) -> bool {
|
pub fn is_mainnet(&self) -> bool {
|
||||||
matches!(self, Network::Mainnet)
|
matches!(self, Network::Mainnet)
|
||||||
|
|
@ -380,9 +376,32 @@ impl ByronAddress {
|
||||||
pub fn typeid(&self) -> u8 {
|
pub fn typeid(&self) -> u8 {
|
||||||
0b1000
|
0b1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to_vec(&self) -> Vec<u8> {
|
||||||
|
self.0.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
let bytes = self.to_vec();
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8]> for ByronAddress {
|
||||||
|
fn as_ref(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ShelleyAddress {
|
impl ShelleyAddress {
|
||||||
|
pub fn new(
|
||||||
|
network: Network,
|
||||||
|
payment: ShelleyPaymentPart,
|
||||||
|
delegation: ShelleyDelegationPart,
|
||||||
|
) -> Self {
|
||||||
|
Self(network, payment, delegation)
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the network assoaciated with this address
|
/// Gets the network assoaciated with this address
|
||||||
pub fn network(&self) -> Network {
|
pub fn network(&self) -> Network {
|
||||||
self.0
|
self.0
|
||||||
|
|
@ -391,13 +410,13 @@ impl ShelleyAddress {
|
||||||
/// Gets a numeric id describing the type of the address
|
/// Gets a numeric id describing the type of the address
|
||||||
pub fn typeid(&self) -> u8 {
|
pub fn typeid(&self) -> u8 {
|
||||||
match (&self.1, &self.2) {
|
match (&self.1, &self.2) {
|
||||||
(ShelleyPaymentPart::PaymentKey(_), ShelleyDelegationPart::StakeKey(_)) => 0b0000,
|
(ShelleyPaymentPart::Key(_), ShelleyDelegationPart::Key(_)) => 0b0000,
|
||||||
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::StakeKey(_)) => 0b0001,
|
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Key(_)) => 0b0001,
|
||||||
(ShelleyPaymentPart::PaymentKey(_), ShelleyDelegationPart::Script(_)) => 0b0010,
|
(ShelleyPaymentPart::Key(_), ShelleyDelegationPart::Script(_)) => 0b0010,
|
||||||
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Script(_)) => 0b0011,
|
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Script(_)) => 0b0011,
|
||||||
(ShelleyPaymentPart::PaymentKey(_), ShelleyDelegationPart::Pointer(_)) => 0b0100,
|
(ShelleyPaymentPart::Key(_), ShelleyDelegationPart::Pointer(_)) => 0b0100,
|
||||||
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Pointer(_)) => 0b0101,
|
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Pointer(_)) => 0b0101,
|
||||||
(ShelleyPaymentPart::PaymentKey(_), ShelleyDelegationPart::Null) => 0b0110,
|
(ShelleyPaymentPart::Key(_), ShelleyDelegationPart::Null) => 0b0110,
|
||||||
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Null) => 0b0111,
|
(ShelleyPaymentPart::Script(_), ShelleyDelegationPart::Null) => 0b0111,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -435,6 +454,17 @@ impl ShelleyAddress {
|
||||||
[&[header], payment.as_slice(), delegation.as_slice()].concat()
|
[&[header], payment.as_slice(), delegation.as_slice()].concat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
let bytes = self.to_vec();
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_bech32(&self) -> Result<String, Error> {
|
||||||
|
let hrp = self.hrp()?;
|
||||||
|
let bytes = self.to_vec();
|
||||||
|
encode_bech32(&bytes, hrp)
|
||||||
|
}
|
||||||
|
|
||||||
/// Indicates if either the payment or delegation part is a script
|
/// Indicates if either the payment or delegation part is a script
|
||||||
pub fn has_script(&self) -> bool {
|
pub fn has_script(&self) -> bool {
|
||||||
self.payment().is_script() || self.delegation().is_script()
|
self.payment().is_script() || self.delegation().is_script()
|
||||||
|
|
@ -493,6 +523,17 @@ impl StakeAddress {
|
||||||
[&[header], self.1.as_ref()].concat()
|
[&[header], self.1.as_ref()].concat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
let bytes = self.to_vec();
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_bech32(&self) -> Result<String, Error> {
|
||||||
|
let hrp = self.hrp()?;
|
||||||
|
let bytes = self.to_vec();
|
||||||
|
encode_bech32(&bytes, hrp)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_script(&self) -> bool {
|
pub fn is_script(&self) -> bool {
|
||||||
self.payload().is_script()
|
self.payload().is_script()
|
||||||
}
|
}
|
||||||
|
|
@ -501,7 +542,11 @@ impl StakeAddress {
|
||||||
impl Address {
|
impl Address {
|
||||||
/// Tries to encode an Address into a bech32 string
|
/// Tries to encode an Address into a bech32 string
|
||||||
pub fn to_bech32(&self) -> Result<String, Error> {
|
pub fn to_bech32(&self) -> Result<String, Error> {
|
||||||
address_to_bech32(self)
|
match self {
|
||||||
|
Address::Byron(_) => Err(Error::InvalidForByron),
|
||||||
|
Address::Shelley(x) => x.to_bech32(),
|
||||||
|
Address::Stake(x) => x.to_bech32(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to parse a bech32 address into an Address
|
/// Tries to parse a bech32 address into an Address
|
||||||
|
|
@ -552,6 +597,22 @@ impl Address {
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_vec(&self) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
Address::Byron(x) => x.to_vec(),
|
||||||
|
Address::Shelley(x) => x.to_vec(),
|
||||||
|
Address::Stake(x) => x.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_hex(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Address::Byron(x) => x.to_hex(),
|
||||||
|
Address::Shelley(x) => x.to_hex(),
|
||||||
|
Address::Stake(x) => x.to_hex(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ByronAddress> for Address {
|
impl From<ByronAddress> for Address {
|
||||||
|
|
@ -639,7 +700,7 @@ mod tests {
|
||||||
match addr {
|
match addr {
|
||||||
Address::Shelley(x) => {
|
Address::Shelley(x) => {
|
||||||
match x.payment() {
|
match x.payment() {
|
||||||
ShelleyPaymentPart::PaymentKey(hash) => {
|
ShelleyPaymentPart::Key(hash) => {
|
||||||
let expected = &hash_vector_key(PAYMENT_PUBLIC_KEY);
|
let expected = &hash_vector_key(PAYMENT_PUBLIC_KEY);
|
||||||
assert_eq!(hash, expected);
|
assert_eq!(hash, expected);
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +712,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
|
|
||||||
match x.delegation() {
|
match x.delegation() {
|
||||||
ShelleyDelegationPart::StakeKey(hash) => {
|
ShelleyDelegationPart::Key(hash) => {
|
||||||
let expected = &hash_vector_key(STAKE_PUBLIC_KEY);
|
let expected = &hash_vector_key(STAKE_PUBLIC_KEY);
|
||||||
assert_eq!(hash, expected);
|
assert_eq!(hash, expected);
|
||||||
}
|
}
|
||||||
|
|
@ -683,4 +744,19 @@ mod tests {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn construct_from_parts() {
|
||||||
|
let payment_hash = hash_vector_key(PAYMENT_PUBLIC_KEY);
|
||||||
|
let delegation_hash = hash_vector_key(STAKE_PUBLIC_KEY);
|
||||||
|
|
||||||
|
let addr: Address = ShelleyAddress::new(
|
||||||
|
Network::Mainnet,
|
||||||
|
ShelleyPaymentPart::key_hash(payment_hash),
|
||||||
|
ShelleyDelegationPart::key_hash(delegation_hash),
|
||||||
|
)
|
||||||
|
.into();
|
||||||
|
|
||||||
|
assert_eq!(addr.to_bech32().unwrap(), MAINNET_TEST_VECTORS[0].0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue